defenseunicorns / defenseunicorns/uds-cli
`uds create --signing-key` cannot sign unattended when the cosign key has an empty passphrase
- Dominant language
- Go
- Stars
- 54
- Forks
- 21
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 45
Description
### Environment
Device and OS:
App version: `uds` v0.34.3 (behaviour also present in earlier releases)
Kubernetes distro being used: N/A — reproduces at build time, no cluster involved
Other: cosign key pair generated with an empty passphrase (`cosign generate-key-pair` + empty prompt). Running non-interactively from a semantic-release `prepareCmd` in CI.
### Steps to reproduce
1. Generate a cosign key pair with an **empty** passphrase:
```bash
COSIGN_PASSWORD='' cosign generate-key-pair
```
2. From a directory containing a valid `uds-bundle.yaml`, run a fully non-interactive create:
```bash
uds create . \
--signing-key ./cosign.key \
--signing-key-password '' \
--confirm --no-color --no-progress
```
3. Repeat with each of the following, all of which fail the same way:
- `COSIGN_PASSWORD='' uds create . --signing-key ./cosign.key --confirm`
- omitting `--signing-key-password` entirely
- piping input: `uds create . --signing-key ./cosign.key --confirm < /dev/null`
### Expected result
With `--confirm` set (and/or an explicitly-provided empty `--signing-key-password`, and/or `COSIGN_PASSWORD` exported), `uds create` signs the bundle without prompting, and exits non-zero on failure rather than blocking. Bundles signed with an empty-passphrase key should be producible in an unattended pipeline.
### Actual Result
`uds create` blocks on an interactive prompt:
```
? Private key password (empty for no password):
```
On a runner with a TTY this hangs until the job times out. Without a TTY, `survey` errors out instead. Either way there is **no** flag, env var, or config value that lets you get past it, because an empty passphrase is not expressible.
### Visual Proof (screenshots, videos, text, etc)
The relevant code is in [`[src/pkg/bundle/create.go#L75-L86](https://github.com/defenseunicorns/uds-cli/blob/v0.34.3/src/pkg/bundle/create.go#L75-L86)`](https://github.com/defenseunicorns/uds-cli/blob/v0.34.3/src/pkg/bundle/create.go#L75-L86):
```go
getSigCreatePassword := func(_ bool) ([]byte, error) {
if b.cfg.CreateOpts.SigningKeyPassword != "" {
return []byte(b.cfg.CreateOpts.SigningKeyPassword), nil
}
return interactive.PromptSigPassword()
}
// sign the bundle
signBlobOptions := signing.DefaultSignBlobOptions()
signBlobOptions.OutputSignature = filepath.Join(b.tmp, config.BundleYAMLSignature)
signBlobOptions.PassFunc = getSigCreatePassword
signBlobOptions.Key = b.cfg.CreateOpts.SigningKeyPath
```
Three separate consequences, all from these lines:
1. **An empty passphrase is unrepresentable.** `SigningKeyPassword` is a plain `string` and the guard is `!= ""`, so "the user explicitly passed an empty password" and "the user passed nothing" collapse into the same case. An empty passphrase is valid for cosign, so this is a genuine gap rather than a missing feature.
2. **`COSIGN_PASSWORD` is ignored.** Assigning `signBlobOptions.PassFunc` overrides cosign's own env-aware pass function, which would otherwise honour `COSIGN_PASSWORD` (cosign uses `os.LookupEnv`, so it handles the empty-string case correctly).
3. **`--confirm` is not consulted here.** [`[interactive.PromptSigPassword()](https://github.com/defenseunicorns/uds-cli/blob/v0.34.3/src/pkg/interactive/interactive.go#L12-L23)`](https://github.com/defenseunicorns/uds-cli/blob/v0.34.3/src/pkg/interactive/interactive.go#L12-L23) uses `survey.Password`, which requires a TTY. Zarf's equivalent path returns early when confirm is set; UDS CLI does not, so `--confirm` does not imply non-interactive here even though that is what it implies everywhere else in the command.
For comparison, Zarf exposes `zarf package sign ... --signing-key-pass`, so a package can be signed after the fact. UDS CLI has no `uds sign` command, and signing happens mid-`create` (the signature is written to the temp dir and pushed as an OCI layer by the bundler), so there is no supported way to sign a bundle out of band either.
### Severity/Priority
Medium. It does not affect deployed bundles, but it blocks automated signed-bundle releases for anyone using an empty-passphrase key, and the failure mode in CI is a hang rather than a clear error. There is a workaround (re-key with a non-empty passphrase), but it is not discoverable from the CLI output or the docs — the prompt explicitly advertises "empty for no password", which is exactly the case that cannot be automated.
### Additional Context
Possible fixes, roughly in increasing order of completeness:
- **Honour `--confirm`.** Return `nil, nil` (or `[]byte{}, nil`) instead of prompting when `config.CommonOptions.Confirm` is set. Smallest change; makes `--confirm` mean the same thing here as elsewhere.
- **Fall back to cosign's own pass function.** Leave `signBlobOptions.PassFunc` unset when no password flag was supplied, so `COSIGN_PASSWORD` works as users coming from cosign would expect.
- **Make "empty" expressible.** Use `cmd.Flags().Changed("signing-key-password")` rather than a `!= ""` comparison, so an explicit `--signing-key-password ''` is respected.
- **Add `--signing-key-password-file`.** Avoids passphrases in process args / CI logs, and sidesteps the empty-string ambiguity entirely. Useful independent of this bug.
- **Optionally, add a `uds sign` command** mirroring `zarf package sign`, for the post-creation signing case.
Happy to open a PR for whichever direction maintainers prefer.
Contributor guide
Research direction
Start in src/pkg/bundle/create.go at the signing password callback and compare its behavior with src/pkg/interactive/interactive.go and the documented --confirm path. Verify how an explicitly empty password and COSIGN_PASSWORD are distinguished, then exercise unattended uds create signing with an empty-passphrase key; done means it does not prompt and exits non-zero on signing failure.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100