diff --git a/cmd/app_secret.go b/cmd/app_secret.go index a21b24c..e264812 100644 --- a/cmd/app_secret.go +++ b/cmd/app_secret.go @@ -1,20 +1,24 @@ package cmd import ( + "crypto/rand" + "encoding/base64" "errors" "fmt" "io" "os" "path/filepath" "reflect" + "strings" + "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/openbao" "github.com/spf13/cobra" ) -var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretGrantEnvironment string -var appSecretShared, appSecretDeleteYes bool +var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretIdentity, appSecretGrantEnvironment string +var appSecretShared, appSecretDeleteYes, appSecretGenerate bool var appSecretGrantShared, appSecretGrantSecrets []string var loadAppSecretConfig = config.Load @@ -70,8 +74,10 @@ func init() { appSecretCmd.PersistentFlags().StringVar(&appSecretConfigPath, "config", "", "Path to private bootstrap config YAML") _ = appSecretCmd.MarkPersistentFlagRequired("config") appSecretCmd.PersistentFlags().StringVar(&appSecretTokenFile, "token-file", "", "Path to restricted OpenBao token file for secret CRUD") + appSecretCmd.PersistentFlags().StringVar(&appSecretIdentity, "identity", "", "Encrypted operational identity: admin or e2e:") appSecretSetCmd.Flags().StringVar(&appSecretFile, "file", "", "Read the secret value from this file instead of stdin") + appSecretSetCmd.Flags().BoolVar(&appSecretGenerate, "generate", false, "Generate a random secret value without printing it") for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} { command.Flags().BoolVar(&appSecretShared, "shared", false, "Use shared// instead of apps//") } @@ -90,7 +96,12 @@ func runAppSecretSet(cmd *cobra.Command, args []string) error { if err != nil { return err } - if err := storeAppSecret(kubeconfig, appSecretTokenFile, path, value); err != nil { + tokenFile, cleanup, err := appSecretTokenPath() + if err != nil { + return err + } + defer cleanup() + if err := storeAppSecret(kubeconfig, tokenFile, path, value); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path) @@ -102,7 +113,12 @@ func runAppSecretList(cmd *cobra.Command, args []string) error { if err != nil { return err } - values, err := listAppSecrets(kubeconfig, appSecretTokenFile, appSecretShared, args[0]) + tokenFile, cleanup, err := appSecretTokenPath() + if err != nil { + return err + } + defer cleanup() + values, err := listAppSecrets(kubeconfig, tokenFile, appSecretShared, args[0]) if err != nil { return err } @@ -120,7 +136,12 @@ func runAppSecretDelete(cmd *cobra.Command, args []string) error { if err != nil { return err } - if err := deleteAppSecret(kubeconfig, appSecretTokenFile, path); err != nil { + tokenFile, cleanup, err := appSecretTokenPath() + if err != nil { + return err + } + defer cleanup() + if err := deleteAppSecret(kubeconfig, tokenFile, path); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path) @@ -161,7 +182,12 @@ func runAppSecretStatus(cmd *cobra.Command, args []string) error { if err != nil { return err } - present, err := appSecretStatus(kubeconfig, appSecretTokenFile, path) + tokenFile, cleanup, err := appSecretTokenPath() + if err != nil { + return err + } + defer cleanup() + present, err := appSecretStatus(kubeconfig, tokenFile, path) if err != nil { return err } @@ -201,6 +227,16 @@ func appSecretKubeconfigFromConfig() (string, error) { } func readAppSecretValue(cmd *cobra.Command) ([]byte, error) { + if appSecretGenerate { + if appSecretFile != "" { + return nil, errors.New("--generate and --file cannot be used together") + } + value := make([]byte, 32) + if _, err := rand.Read(value); err != nil { + return nil, err + } + return []byte(base64.RawURLEncoding.EncodeToString(value)), nil + } if appSecretFile != "" { value, err := os.ReadFile(appSecretFile) if err != nil { @@ -214,3 +250,46 @@ func readAppSecretValue(cmd *cobra.Command) ([]byte, error) { } return value, nil } + +func appSecretTokenPath() (string, func(), error) { + if appSecretTokenFile != "" { + return appSecretTokenFile, func() {}, nil + } + if appSecretIdentity == "" { + return "", nil, errors.New("--token-file or --identity is required") + } + cfg, err := loadRequiredAppSecretConfig() + if err != nil { + return "", nil, err + } + path := "" + if appSecretIdentity == "admin" { + path = "cicd/app-secret-admin" + } else if strings.HasPrefix(appSecretIdentity, "e2e:") { + path = "cicd/e2e-" + strings.TrimPrefix(appSecretIdentity, "e2e:") + } else { + return "", nil, errors.New("--identity must be admin or e2e:") + } + secrets, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath) + if err != nil { + return "", nil, errors.New("read encrypted app-secret identity") + } + token := secrets[path]["token"] + if token == "" { + return "", nil, errors.New("configured app-secret identity is absent") + } + file, err := os.CreateTemp("", "maidn-openbao-token-*") + if err != nil { + return "", nil, err + } + if _, err := file.WriteString(token + "\n"); err != nil { + file.Close() + os.Remove(file.Name()) + return "", nil, err + } + if err := file.Close(); err != nil { + os.Remove(file.Name()) + return "", nil, err + } + return file.Name(), func() { _ = os.Remove(file.Name()) }, nil +} diff --git a/cmd/app_secret_test.go b/cmd/app_secret_test.go index 0414735..98ac6f6 100644 --- a/cmd/app_secret_test.go +++ b/cmd/app_secret_test.go @@ -47,6 +47,16 @@ func TestAppSecretDeleteRequiresExplicitConfirmation(t *testing.T) { } } +func TestAppSecretGenerateDoesNotReadOrPrintValue(t *testing.T) { + originalGenerate, originalFile := appSecretGenerate, appSecretFile + t.Cleanup(func() { appSecretGenerate, appSecretFile = originalGenerate, originalFile }) + appSecretGenerate, appSecretFile = true, "" + value, err := readAppSecretValue(&cobra.Command{}) + if err != nil || len(value) < 40 || strings.Contains(string(value), "\n") { + t.Fatal("generated app secret is not a bounded opaque value") + } +} + func TestAppSecretGrantOnlySavesNewDeclarativeDefinition(t *testing.T) { originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index ae56638..2726000 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -24,6 +24,8 @@ var bootstrapInitializeOpenBaoRecovery bool var bootstrapInitializeOpenBao bool var bootstrapCreateForgejoRegistryToken bool var bootstrapCreateForgejoDeliveryStatusToken bool +var bootstrapProvisionAppSecretIdentities bool +var bootstrapE2EApp string var bootstrapRegisterWebhook bool var bootstrapRotateWebhookAuthorization bool var bootstrapMergeBootstrapPR bool @@ -55,6 +57,8 @@ func init() { bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao") + bootstrapCmd.Flags().BoolVar(&bootstrapProvisionAppSecretIdentities, "provision-app-secret-identities", false, "Create restricted app-secret and E2E OpenBao identities") + bootstrapCmd.Flags().StringVar(&bootstrapE2EApp, "e2e-app", "", "Fixture app granted an E2E probe identity") bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook") bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") @@ -66,6 +70,19 @@ func init() { func runBootstrap(cmd *cobra.Command, args []string) error { var cfg config.Config var err error + if bootstrapProvisionAppSecretIdentities { + if bootstrapConfigPath == "" || bootstrapE2EApp == "" { + return errors.New("--provision-app-secret-identities requires --config and --e2e-app") + } + cfg, err = config.Load(bootstrapConfigPath) + if err != nil { + return err + } + if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { + return err + } + return bootstrap.ProvisionAppSecretIdentities(cfg, bootstrapE2EApp) + } if bootstrapCreateForgejoRegistryToken { if bootstrapConfigPath == "" { return fmt.Errorf("--create-forgejo-registry-token requires --config") diff --git a/docs/secret-grants.md b/docs/secret-grants.md index 22fb6ee..d770e23 100644 --- a/docs/secret-grants.md +++ b/docs/secret-grants.md @@ -136,9 +136,10 @@ code and therefore requires a narrowly scoped, disposable credential. ## Operations 1. Create the least-privilege upstream credential. -2. Write its value to the declared OpenBao path with `cicd-tool app secret set` - using stdin or `--file` and a least-privilege `--token-file`. Never put a - value or token in YAML, a URL, a command argument, output, or Git. +2. Write its value with `cicd-tool app secret set` using stdin, `--file`, or + `--generate`. Use a least-privilege `--token-file` or a bootstrap-provisioned + `--identity`; never put a value or token in YAML, a URL, a command argument, + output, or Git. 3. Add the reviewed grant and GitOps resources. 4. Bootstrap or reconcile to create the OpenBao role and policy. 5. Verify the target ExternalSecret becomes Ready without printing its Secret. diff --git a/docs/secrets.md b/docs/secrets.md index 4a7b497..11d2b6b 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -21,3 +21,14 @@ The webhook-only path requires a complete delivery contract, an approved configu - Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation. - Use the sanitized procedure in [runbooks/credential-rotation.md](runbooks/credential-rotation.md) for any live rotation. - Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration. + +## Automated app-secret identities + +Use `bootstrap --provision-app-secret-identities --e2e-app ` to create a +short-lived non-root `admin` identity and an exact-path E2E probe identity. +Bootstrap reads root recovery material only through its encrypted recovery +bundle, writes the generated tokens only into encrypted operational state, and +never prints either value. `app secret --identity admin` and +`app secret --identity e2e:` create a temporary local token file only for +the command lifetime. Root tokens, recovery bundles, and unseal shares are not +valid app-secret identities. diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 3af8d10..80ab212 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -985,6 +985,23 @@ func InitializeOpenBao(cfg config.Config) error { return nil } +// ProvisionAppSecretIdentities rotates short-lived restricted tokens and +// immediately reseeds encrypted operational state through OpenBao. +func ProvisionAppSecretIdentities(cfg config.Config, app string) error { + kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig") + tokens, err := openbao.ProvisionAppSecretIdentities(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, app) + if err != nil { + return err + } + if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/app-secret-admin", "token", tokens.Admin); err != nil { + return errors.New("save app-secret admin token") + } + if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/e2e-"+app, "token", tokens.E2E); err != nil { + return errors.New("save E2E app-secret token") + } + return InitializeOpenBao(cfg) +} + func NewWebhookAuthorization() (string, error) { value := make([]byte, 32) if _, err := rand.Read(value); err != nil { diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go index 3d05db6..d9b2c27 100644 --- a/internal/openbao/bootstrap.go +++ b/internal/openbao/bootstrap.go @@ -20,6 +20,11 @@ import ( "gopkg.in/yaml.v3" ) +type AppSecretIdentityTokens struct { + Admin string + E2E string +} + type status struct { Initialized bool `json:"initialized"` Sealed bool `json:"sealed"` @@ -416,6 +421,61 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [ return nil } +// ProvisionAppSecretIdentities creates short-lived non-root tokens for secret +// administration and one fixture probe. Tokens are returned only to be placed +// into encrypted operational state by the caller. +func ProvisionAppSecretIdentities(kubeconfig, identityPath, bundlePath, app string) (AppSecretIdentityTokens, error) { + if !managedSecretPart.MatchString(app) { + return AppSecretIdentityTokens{}, errors.New("E2E application must be a lowercase DNS label") + } + material, err := ReadRecoveryMaterial(identityPath, bundlePath) + if err != nil { + return AppSecretIdentityTokens{}, err + } + script := `read -r root_token +export BAO_TOKEN="$root_token" +cat >/tmp/maidn-app-secret-admin.hcl <<'EOF' +path "secret/data/apps/*" { capabilities = ["create", "update"] } +path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] } +path "secret/data/shared/*" { capabilities = ["create", "update"] } +path "secret/metadata/shared/*" { capabilities = ["list", "read", "delete"] } +EOF +cat >/tmp/maidn-e2e.hcl <<'EOF' +path "secret/data/apps/` + app + `/E2E_PROBE" { capabilities = ["create", "update"] } +path "secret/metadata/apps/` + app + `" { capabilities = ["list"] } +path "secret/metadata/apps/` + app + `/E2E_PROBE" { capabilities = ["read", "delete"] } +EOF +bao policy write maidn-app-secret-admin /tmp/maidn-app-secret-admin.hcl >/dev/null +bao policy write maidn-e2e-` + app + ` /tmp/maidn-e2e.hcl >/dev/null +bao token create -orphan -policy=maidn-app-secret-admin -ttl=1h -explicit-max-ttl=1h -format=json +bao token create -orphan -policy=maidn-e2e-` + app + ` -ttl=1h -explicit-max-ttl=1h -format=json +rm -f /tmp/maidn-app-secret-admin.hcl /tmp/maidn-e2e.hcl` + output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script) + if err != nil { + return AppSecretIdentityTokens{}, openBaoMutationError("provision app-secret identities", err, output, material.RootToken) + } + var responses []struct { + Auth struct { + ClientToken string `json:"client_token"` + } `json:"auth"` + } + for _, line := range bytes.Split(bytes.TrimSpace(output), []byte("\n")) { + var response struct { + Auth struct { + ClientToken string `json:"client_token"` + } `json:"auth"` + } + if err := json.Unmarshal(line, &response); err != nil || response.Auth.ClientToken == "" { + return AppSecretIdentityTokens{}, errors.New("parse provisioned app-secret identity") + } + responses = append(responses, response) + } + if len(responses) != 2 { + return AppSecretIdentityTokens{}, errors.New("provision app-secret identities returned an incomplete result") + } + return AppSecretIdentityTokens{Admin: responses[0].Auth.ClientToken, E2E: responses[1].Auth.ClientToken}, nil +} + func openBaoMutationError(action string, err error, output []byte, sensitive ...string) error { diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...) if diagnostic == "" && err != nil { diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index 29ef6a2..abac690 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -183,6 +183,25 @@ func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) { } } +func TestProvisionAppSecretIdentitiesScopesFixtureWithoutRootLeak(t *testing.T) { + originalDecrypt, originalMutation := decryptRecovery, execInPodMutation + t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalMutation }) + decryptRecovery = func(string, string) ([]byte, error) { + return []byte(`{"root_token":"root-token","unseal_keys_b64":["share"],"unseal_threshold":1}`), nil + } + execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) { + command := strings.Join(args, " ") + if string(input) != "root-token\n" || !strings.Contains(command, `secret/data/apps/maidn-e2e-web/E2E_PROBE`) || strings.Contains(command, `secret/data/apps/maidn-e2e-web/*`) { + t.Fatal("fixture identity policy scope is incorrect") + } + return []byte("{\"auth\":{\"client_token\":\"admin-token\"}}\n{\"auth\":{\"client_token\":\"e2e-token\"}}\n"), nil + } + tokens, err := ProvisionAppSecretIdentities("kubeconfig", "identity", "bundle", "maidn-e2e-web") + if err != nil || tokens.Admin != "admin-token" || tokens.E2E != "e2e-token" { + t.Fatalf("ProvisionAppSecretIdentities() = %#v, %v", tokens, err) + } +} + func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) { original := execInPodMutation t.Cleanup(func() { execInPodMutation = original })