--- name: "kubeseal" description: "Seal or reseal Kubernetes Secrets into SealedSecrets using kubeseal for secure GitOps storage. Use whenever the user says seal, reseal, or re-seal a secret or SealedSecret, and when they need to create, update, rotate, or fix SealedSecrets, seal credentials for GitOps repos, or migrate secrets between services. NEVER leak plaintext credentials or seal keys in output, logs, or files." license: MIT allowed-tools: Bash, Read, Write, Glob, Grep, mcp__kubernetes__resources_get, mcp__kubernetes__resources_list --- # Kubeseal: Seal and Reseal Kubernetes Secrets ## Overview Seal Kubernetes Secrets into Bitnami SealedSecrets that are safe to store in Git. SealedSecrets are asymmetrically encrypted — anyone can encrypt (seal), but only the sealed-secrets controller in the cluster can decrypt (unseal). > Validated against `kubeseal` v0.38.4. Flag names are stable across recent releases, but run `kubeseal --help` if a command behaves unexpectedly. **Key insight:** the public certificate (`tls.crt`) is *not* secret — it is a public key, safe to share and even commit. Only the controller's private key (`tls.key`) is sensitive. Sealing is a purely local, offline operation once you have the cert; no cluster access is needed to seal. ## CRITICAL SECURITY RULES ### NEVER LEAK CREDENTIALS 1. **NEVER print plaintext credentials** to stdout, conversation output, or any readable medium. 2. **NEVER write plaintext secrets to files** in the repository or any version-controlled location. 3. **NEVER echo or log secret values** — use variables and pipe directly. 4. **NEVER include plaintext secrets in commit messages, PR descriptions, or comments.** 5. **ALWAYS overwrite or zero-out temp files** containing plaintext secrets immediately after use. 6. **NEVER leak seal key material** — the private key (`tls.key`) must never be printed, committed, or shared. Only the public key (`tls.crt`) is needed for sealing. ### Temp File Handling **Prefer approaches that never write plaintext to a named file on disk** — the stdin heredoc under "1. New SealedSecret", or "Encrypt a Single Value (`--raw`)", both below. When a temp file is unavoidable: - Write temp secret files to `/tmp/` with descriptive names. - After sealing, **overwrite** the temp file contents with empty or garbage data. - Never `rm` temp files (may be blocked by permission rules) — overwrite instead: ```bash echo "" > /tmp/secret-temp.yaml ``` - Never commit temp files to the repository. > Note: the public cert (`tls.crt` / `.pem`) is **not** sensitive and does **not** need sanitizing — sealing it away only forces a re-fetch. Only overwrite files that contain *plaintext secret values* or the *private key*. ## Prerequisites - `kubeseal` CLI installed and available - Public key certificate (`tls.crt`) — obtained **only** as described in "Obtaining the Certificate" below - Access to the Kubernetes cluster via the Kubernetes MCP (for the cert fallback, and for retrieving existing secret values) ## Obtaining the Certificate **This section is authoritative. The certificate MUST come from one of exactly two sources, tried in this order.** Do not improvise a third. ### Source 1 (preferred) — the project's `certs/` folder Look for the cert in the repository first. Sealing is offline; if the repo ships the cert, no cluster access is needed at all. ```bash # From the project root; typical names: tls.crt, sealed-secrets-cert.pem, sealed-secrets.crt ls certs/ # If certs/ is not at the root, locate it: find . -type d -name certs -not -path '*/.git/*' ``` Set `CERT` to the file you found, then **validate it before use** (see "Always Validate the Certificate"): ```bash CERT=certs/tls.crt ``` If `certs/` holds more than one candidate, validate each and prefer the one that verifies against the repo's existing SealedSecrets. ### Source 2 (fallback) — the cluster, via the Kubernetes MCP Only if `certs/` has no valid cert. Use the **Kubernetes MCP tools** to read the controller's active key Secret: **Step 1.** Call `mcp__kubernetes__resources_list` with: - `apiVersion: v1`, `kind: Secret` - `labelSelector: sealedsecrets.bitnami.com/sealed-secrets-key=active` - `namespace`: wherever the controller runs (commonly `kube-system` or `sealed-secrets`); omit to search all namespaces If several `active` key Secrets come back, pick the one with the most recent `metadata.creationTimestamp` — that is the key the controller currently seals with. **Step 2.** Take `data["tls.crt"]` from that Secret and base64-decode it into a local file: ```bash printf '%s' '' | base64 -d > /tmp/sealed-secrets-cert.pem CERT=/tmp/sealed-secrets-cert.pem ``` **Step 3.** Validate it before use (next section). If the MCP returns no such Secret, the controller is not installed on the cluster the MCP points at. **Stop and tell the user** — do not fall back to another method. > **DANGER — that Secret also contains `tls.key`, the controller's private key.** Read and use **only** the `tls.crt` field. Never decode, print, echo, write, or commit `tls.key`. Do not paste the raw MCP response anywhere. ### Always Validate the Certificate **A file is not a certificate just because it exists.** Redirects capture error text into cert-shaped files, and sealing against that garbage produces a SealedSecret that fails later with `illegal base64 data at input byte N` or `no key could decrypt secret` — long after the plaintext is gone. **Run this before every seal. If it fails, STOP** — fall back to the next source rather than sealing: ```bash openssl x509 -in "$CERT" -noout -subject -dates || echo "NOT A VALID CERT — do not seal with this file" ``` If a `certs/` file fails validation, report it to the user and move to Source 2. Never "fix" it by guessing. ### Confirm It Is the Right Key (recommended) A valid cert may still be the *wrong* or a *rotated* key. When the repo already contains working SealedSecrets, confirm the cert matches the one they were sealed with by comparing public keys against a known-good pair, or by checking that a newly sealed test value round-trips. If the repo also contains the private key, note that as a security problem (see Pitfalls) rather than relying on it. ## Workflow ### 1. New SealedSecret **Preferred — seal via stdin (no plaintext file on disk):** The sealed output is safe to write directly into the repo. Only the *input* is sensitive, and piping it via a heredoc keeps it off the filesystem entirely. ```bash kubeseal --cert "$CERT" -o yaml -f /dev/stdin > path/to/repo/sealedsecret.yaml <<'EOF' apiVersion: v1 kind: Secret metadata: name: my-secret namespace: my-namespace type: Opaque stringData: key1: "value1" key2: "value2" EOF ``` **Alternative — temp file (when you must inspect/edit the plaintext first):** ```bash # Write secret to temp file (NEVER to repo) cat > /tmp/my-secret.yaml <<'EOF' apiVersion: v1 kind: Secret metadata: name: my-secret namespace: my-namespace type: Opaque stringData: key1: "value1" key2: "value2" EOF # Seal and output YAML (sealed output is safe to write straight to the repo) kubeseal --cert "$CERT" --format yaml -f /tmp/my-secret.yaml > path/to/repo/sealedsecret.yaml # SANITIZE the plaintext temp file immediately (the sealed output is not sensitive) echo "" > /tmp/my-secret.yaml ``` ### 2. Reseal / Update an Existing SealedSecret (Preserving Fields) > This is the path for a **reseal** request — the user asks to reseal an existing SealedSecret, usually because it fails to unseal (`illegal base64 data at input byte N`, `no key could decrypt secret`) or because a credential changed. Resealing rewrites `spec.encryptedData` in place; keep `metadata`, `spec.template` (labels, annotations, `type`), name, and namespace byte-identical to the original unless the user asks otherwise, and match the surrounding files' conventions. When updating some keys in a SealedSecret (e.g., changing S3 credentials but keeping a database password), you have two options: - **Update individual keys** with `--raw` (paste into `encryptedData`) or `--merge-into` — you only need the values of the keys you're changing. - **Re-seal the whole secret** — you need the plaintext of ALL keys, retrieved from the cluster. #### Step 1: Retrieve existing secret from the cluster Prefer the `mcp__kubernetes__resources_get` MCP tool to read the decrypted Secret — the SealedSecret controller auto-decrypts into a regular Secret in the cluster (`apiVersion: v1`, `kind: Secret`, plus the name and namespace). Or via kubectl: ```bash kubectl get secret -n -o jsonpath='{.data}' ``` #### Step 2: Decode and re-seal with updated values > **DANGER — do not interpolate secret values into YAML.** A value containing `"`, `:`, `\n`, leading spaces, or `$` will break the quoting and either corrupt the secret or make `kubeseal` fail with `error: no secrets found`. This is common with generated passwords and S3 keys. **Never** build YAML like `password: "$EXISTING_PASS"`. **Safe approach — seal each key individually with `--raw`, then merge.** `--raw` reads the value from stdin as raw bytes (no YAML quoting involved), so special characters are handled correctly. Under the default `strict` scope you must pass `--name` and `--namespace`, and they must match the target SealedSecret. ```bash NS=my-namespace NAME=my-secret # Decode a value you want to PRESERVE (capture in variable, NEVER echo) EXISTING_PASS=$(printf '%s' '' | base64 -d) # Re-seal the preserved value and the updated value straight into the repo file. # printf '%s' avoids adding a trailing newline to the secret. printf '%s' "$EXISTING_PASS" | kubeseal --cert "$CERT" \ --raw --namespace "$NS" --name "$NAME" --from-file=/dev/stdin # -> paste the output under spec.encryptedData.password in path/to/repo/sealedsecret.yaml printf '%s' 'new-value' | kubeseal --cert "$CERT" \ --raw --namespace "$NS" --name "$NAME" --from-file=/dev/stdin # -> paste the output under spec.encryptedData.access-key-id ``` Or use `--merge-into` (see below) to update individual keys in place without touching the others. **If you must build a full Secret object** (e.g. a fresh SealedSecret from scratch), avoid quoting pitfalls by base64-encoding values into the `data:` field instead of `stringData:`: ```bash cat > /tmp/updated-secret.yaml < path/to/repo/sealedsecret.yaml # SANITIZE the plaintext temp file echo "" > /tmp/updated-secret.yaml ``` ### 3. Obtain the Public Key See **"Obtaining the Certificate"** above — `certs/` first, then the Kubernetes MCP. No other source is permitted, and the cert must be validated with `openssl x509` before sealing. ## kubeseal Command Reference ### Common Flags | Flag | Purpose | |------|---------| | `--cert ` | Public key file for encryption. **Always pass this explicitly** — omitting it makes kubeseal auto-detect the controller. Use a local file only, never a URL | | `-o, --format yaml\|json` | Output format (default: json) | | `-f, --secret-file ` | Input Secret YAML file (use `/dev/stdin` to pipe) | | `-n, --namespace ` | Namespace scope for **the secret being sealed** (not the controller's location) | | `--scope strict\|namespace-wide\|cluster-wide` | Scoping of the sealed secret (default: `strict`) | | `--merge-into ` | Merge sealed keys into existing SealedSecret file (in-place) | | `--raw` | Encrypt a single raw value from `--from-file`; requires `--scope`, plus `--name`+`--namespace` for strict scope | | `--from-file ` | (with `--raw`) Source the value from a file; use `/dev/stdin` for a pipe | | `--name ` | Name of the sealed secret (required with `--raw` under strict scope) | | `--re-encrypt` | Re-encrypt an existing SealedSecret with the controller's latest key (needs cluster) | | `--validate` | Verify the sealed secret decrypts — **contacts the controller; requires cluster access** | | `--fetch-cert` | **FORBIDDEN — do not use.** Get the cert from `certs/` or the Kubernetes MCP instead (see "Obtaining the Certificate"). On failure it emits an error message to stdout that a redirect turns into a bogus cert file | | `--controller-namespace ` | Namespace where the controller runs (default: `kube-system`). Applies to `--validate`/`--re-encrypt` only — never for fetching the cert | | `--controller-name ` | Controller name (default: `sealed-secrets-controller`). Same restriction as above | | `--recovery-unseal` | Disaster-recovery decrypt using `--recovery-private-key` (**handles the private key — use with extreme caution**) | ### Scope Modes - **`strict`** (default): Can only be unsealed with the exact namespace and name. - **`namespace-wide`**: Can be unsealed in the specified namespace with any name. - **`cluster-wide`**: Can be unsealed in any namespace with any name. ### Merge Into Existing To update individual keys without re-sealing the entire secret (useful when you can't retrieve all plaintext values): ```bash # Seal only the keys you want to update cat > /tmp/delta-secret.yaml <<'EOF' apiVersion: v1 kind: Secret metadata: name: my-secret namespace: my-namespace type: Opaque stringData: new-key: "new-value" EOF # Merge into existing sealed secret kubeseal --cert "$CERT" --merge-into path/to/repo/sealedsecret.yaml -f /tmp/delta-secret.yaml # SANITIZE echo "" > /tmp/delta-secret.yaml ``` **WARNING**: `--merge-into` adds or updates keys but does NOT remove keys. To remove a key, you must re-seal the entire secret from scratch. ### Encrypt a Single Value (`--raw`) `--raw` encrypts one value and prints the ciphertext string — you paste it under `spec.encryptedData.` yourself. This is the safest way to handle values with special characters (it reads raw bytes from stdin, bypassing YAML quoting entirely) and it never writes plaintext to a file. ```bash # strict scope (default): --name AND --namespace are REQUIRED and must match the target SealedSecret printf '%s' 'p@ss"word:with$pecial' | kubeseal --cert "$CERT" \ --raw --namespace my-namespace --name my-secret --from-file=/dev/stdin # namespace-wide: only --namespace required printf '%s' 'value' | kubeseal --cert "$CERT" \ --raw --scope namespace-wide --namespace my-namespace --from-file=/dev/stdin # cluster-wide: neither required printf '%s' 'value' | kubeseal --cert "$CERT" \ --raw --scope cluster-wide --from-file=/dev/stdin ``` - Use `printf '%s'` (not `echo`) to avoid appending a trailing newline to the secret value. - The `--raw` ciphertext is bound to the scope/name/namespace you pass. If they don't match the SealedSecret it's pasted into, the controller will refuse to unseal it. ### Rotate to a New Controller Key (`--re-encrypt`) After the controller's key pair is rotated, existing SealedSecrets still decrypt (the controller keeps old keys) but should be re-encrypted to the latest key. This requires cluster access: ```bash kubeseal --re-encrypt -o yaml -f path/to/repo/sealedsecret.yaml > /tmp/reencrypted.yaml cp /tmp/reencrypted.yaml path/to/repo/sealedsecret.yaml ``` `--re-encrypt` never exposes plaintext — the re-encryption happens inside the controller. ## SealedSecret YAML Structure ```yaml apiVersion: bitnami.com/v1alpha1 kind: SealedSecret metadata: name: my-secret namespace: my-namespace spec: encryptedData: key1: AgC...base64encrypteddata...== key2: AgC...base64encrypteddata...== template: metadata: name: my-secret namespace: my-namespace type: Opaque ``` ## Common Patterns ### S3-Compatible Object Store Credentials ```yaml apiVersion: v1 kind: Secret metadata: name: s3-credentials namespace: my-namespace type: Opaque stringData: access-key-id: "" secret-access-key: "" ``` ### Database Credentials ```yaml apiVersion: v1 kind: Secret metadata: name: db-credentials namespace: my-namespace type: Opaque stringData: username: "admin" password: "" ``` ### TLS Certificate ```yaml apiVersion: v1 kind: Secret metadata: name: tls-cert namespace: my-namespace type: kubernetes.io/tls stringData: tls.crt: | -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- tls.key: | -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY----- ``` ## Pitfalls and Gotchas 1. **Each key is encrypted independently** — you can't mix encrypted values from different sealing operations into the same `encryptedData` block without re-sealing. Use `--merge-into` or re-seal the entire secret. 2. **SealedSecrets are bound to the controller's key pair** — the controller retains old keys after rotation, so existing SealedSecrets keep decrypting. But new seals need the current public cert, and best practice is to `kubeseal --re-encrypt` existing SealedSecrets onto the latest key. If old keys are ever purged, un-re-encrypted SealedSecrets become undecryptable. 3. **`stringData` vs `data`** — use `stringData` for plaintext values (kubeseal handles encoding). Use `data` for pre-base64-encoded values. 4. **Scope matters** — a `strict`-scoped SealedSecret can only be unsealed with the exact name and namespace. If you rename the secret, it won't unseal. Use `namespace-wide` or `cluster-wide` if name changes are expected. 5. **Temp file hygiene** — always overwrite temp files after sealing. Never leave plaintext secrets on disk. 6. **Git history** — if a secret was accidentally committed, it remains in git history. Use `git filter-repo` to remove it, then rotate the credential. 7. **A cert-shaped file may not be a cert (seen in the wild).** `kubeseal --fetch-cert > certs/sealed-secrets-cert.pem` against a cluster with no controller writes this into the file: ``` error: cannot get sealed secret service: services "sealed-secrets-controller" not found. ``` Sealing against it appears to succeed, and the corruption only surfaces at unseal time as `illegal base64 data at input byte N` or `no key could decrypt secret` — by which point the plaintext may be gone. This is exactly why `--fetch-cert` is forbidden and why `openssl x509` validation is mandatory before every seal. A cert file whose size is a few hundred bytes with no `-----BEGIN CERTIFICATE-----` line is this failure. 8. **A private key in `certs/` is a security incident, not a convenience.** If `certs/` contains `tls.key` alongside the cert, anyone with repo access can decrypt every SealedSecret in it. Report it: the controller key should be rotated and the file purged from git history. Only ever read `tls.crt` for sealing. ## Verification **Before deploying**, validate the sealed secret against the live controller (this contacts the cluster — it does not work offline): ```bash # --validate talks to the controller (no --cert needed); add --controller-namespace/--controller-name if non-default kubeseal --validate -f path/to/repo/sealedsecret.yaml ``` **After deploying**, verify it unsealed correctly: ```bash # Check the Secret exists and has the expected keys kubectl get secret -n -o jsonpath='{.data}' | python3 -c "import sys,json; [print(k) for k in json.load(sys.stdin).keys()]" ``` Or use the `mcp__kubernetes__resources_get` MCP tool to inspect the decrypted Secret. ## Post-Sealing Checklist - [ ] Cert came from `certs/` or the Kubernetes MCP — never `--fetch-cert`, `kubectl`, or a URL - [ ] Cert passed `openssl x509` validation before sealing - [ ] Temp files overwritten (not just deleted) - [ ] No plaintext credentials in shell history (use `set +o history` before sensitive operations) - [ ] No credentials in git diff output - [ ] SealedSecret YAML committed to the correct branch - [ ] No seal private keys (`tls.key`) in the repository or output - [ ] Verified the sealed secret unseals correctly on the cluster