From a59e4167894119e7c556c6c3849c5a6a2e7560bb Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 21 Feb 2026 14:52:48 +0100 Subject: [PATCH] feat: add AES-256-CBC encrypted password storage (v0.2.0) Add `login --save --encrypt` flag: passwords are encrypted with openssl AES-256-CBC (PBKDF2) and stored as `enc:` in the config file. A master passphrase is prompted once per session and cached in memory. Both load_config() and resolve_registry_alias() detect the enc: prefix and decrypt transparently. The passphrase is passed to openssl via a temp file to avoid argv/env exposure. openssl is an optional dependency, checked on demand. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 50 +++++++++++++++++++++++- README.md | 69 +++++++++++++++++++++++++-------- hubmanager | 109 +++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 204 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e21ae8a..fbbc331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,11 +10,48 @@ authentication detection (bearer token or HTTP basic auth). --- +## What Changed (Session 2026-02-21) + +### Encrypted config values (`--encrypt`) + +Added `openssl` AES-256-CBC encryption for passwords stored in the config file. +`openssl` is an **optional** dependency — it is only required when `enc:` prefixed values +are present in the config, or when `login --encrypt` is used. + +**New functions** (Encryption helpers section): + +| Function | Purpose | +| --- | --- | +| `_require_openssl()` | Die with a clear message if `openssl` is not installed | +| `_prompt_master_pass()` | Prompt once per session via `/dev/tty`; cache in `HM_MASTER_PASS` | +| `_prompt_set_master_pass()` | Prompt + confirm a new passphrase (used by `login --encrypt`) | +| `_encrypt_value(plaintext)` | AES-256-CBC encrypt → base64 ciphertext (no newlines) | +| `_decrypt_value(ciphertext)` | Decrypt base64 ciphertext → plaintext; die on wrong passphrase | + +**Passphrase security**: passed to `openssl` via a `mktemp` file (`-pass file:`) to avoid +exposure in the process argument list (`ps aux`). The temp file is registered in +`HM_TMPFILES` and removed on exit. + +**Config format**: encrypted values use an `enc:` prefix, e.g.: + +```txt +PASSWORD=enc:U2FsdGVkX1+...base64ciphertext... +REGISTRY_PROD_PASSWORD=enc:U2FsdGVkX1+... +``` + +Both `load_config()` and `resolve_registry_alias()` detect the prefix and call +`_decrypt_value` transparently. + +**Version bump**: `0.1.0` → `0.2.0` + +--- + ## What Was Built (Session 2025-02-21) ### Primary file -`hubmanager` — executable Bash script, ~600 lines, no dependencies beyond `curl`, `jq`, Bash 4+. +`hubmanager` — executable Bash script, ~680 lines, no mandatory dependencies beyond `curl`, `jq`, Bash 4+. +`openssl` is required only when encrypted config values are used. ### Subcommands implemented @@ -47,6 +84,7 @@ set -euo pipefail # --- Global state --- # --- Output / Formatting helpers --- # --- Dependency check --- +# --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass() # --- Config loading --- # --- HTTP helpers --- raw_http(), get_response_header() # --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header() @@ -96,6 +134,15 @@ main "$@" - Blob already at destination (`HEAD` returns 200) → skip - Otherwise → download to temp file → `POST` initiate upload → `PUT` with digest +9. **Encrypted config values** (v0.2.0): + - `login --save --encrypt` prompts for a master passphrase (with confirmation), encrypts + the password with `openssl enc -aes-256-cbc -pbkdf2 -a`, and writes `PASSWORD=enc:`. + - Passphrase is passed to `openssl` via a temp file (`-pass file:`) — never via argv or env. + - `load_config` and `resolve_registry_alias` both check for the `enc:` prefix and call + `_decrypt_value`, which triggers `_prompt_master_pass` (once per session, cached in + `HM_MASTER_PASS`). + - `openssl` is an optional dependency: not checked at startup, only on first `enc:` encounter. + --- ## Global Variables (key ones) @@ -110,6 +157,7 @@ main "$@" | `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL | | `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token | | `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) | +| `HM_MASTER_PASS` | Master passphrase for `enc:` config values (session-cached) | | `HM_LAST_HTTP_CODE` | HTTP status of most recent request | | `HM_LAST_HEADERS_FILE` | Temp file path with response headers | | `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` | diff --git a/README.md b/README.md index 9bcb3a6..89f7c4e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub a - **Bash** 4.0+ - **curl** - **jq** +- **openssl** *(optional — required only when using encrypted config values)* ## Installation @@ -25,6 +26,10 @@ install -m 755 hubmanager ~/bin/hubmanager hubmanager login --registry https://registry.example.com \ --user admin --password secret --save +# Save with password encrypted at rest +hubmanager login --registry https://registry.example.com \ + --user admin --password secret --save --encrypt + # List all repositories hubmanager list @@ -74,9 +79,37 @@ hubmanager --registry staging list hubmanager --registry hub tags myuser/myapp ``` +### Encrypted credentials + +Use `login --save --encrypt` to store the password encrypted with AES-256-CBC. +A master passphrase is prompted at save time (with confirmation) and on every subsequent +invocation that reads the config file. Requires `openssl`. + +```bash +hubmanager login --registry https://registry.example.com \ + --user admin --password secret --save --encrypt +# New master passphrase: **** +# Confirm master passphrase: **** +# Login Succeeded — bearer auth, registry: https://registry.example.com +# Credentials saved to /home/user/.hubmanager.conf +# Password stored encrypted (AES-256-CBC). Master passphrase required on each use. +``` + +The config file stores an `enc:` prefixed ciphertext: + +```text +REGISTRY=https://registry.example.com +USERNAME=admin +PASSWORD=enc:U2FsdGVkX1+...base64ciphertext... +``` + +The `enc:` prefix also works for named alias passwords (`REGISTRY__PASSWORD`). +On every command that reads the config, the master passphrase is prompted once and cached +for the duration of the session. + ## Global Options -``` +```text hubmanager [OPTIONS] [COMMAND OPTIONS] -r, --registry Registry base URL @@ -98,11 +131,12 @@ hubmanager [OPTIONS] [COMMAND OPTIONS] ### `login` — Test and save credentials -``` -hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] +```text +hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] [--encrypt] ``` Validates credentials against the registry. Use `--save` to write them to the config file. +Add `--encrypt` to store the password encrypted with AES-256-CBC (requires `openssl`). ```bash hubmanager login --registry https://registry.example.com \ @@ -115,7 +149,7 @@ hubmanager login --registry https://registry.example.com \ ### `list` — List repositories -``` +```text hubmanager list [--limit N] [--last REPO] ``` @@ -139,7 +173,7 @@ hubmanager list --json | jq '.repositories[]' ### `tags` — List tags for an image -``` +```text hubmanager tags [--limit N] [--last TAG] ``` @@ -162,7 +196,7 @@ hubmanager tags myuser/myapp --json | jq '.tags[]' ### `inspect` — Show image details -``` +```text hubmanager inspect : [--platform OS/ARCH] ``` @@ -201,7 +235,7 @@ hubmanager inspect myuser/myapp:latest --json | jq . ### `delete` — Delete a tag or manifest -``` +```text hubmanager delete : [--yes] ``` @@ -225,9 +259,11 @@ hubmanager delete myuser/myapp@sha256:abc123... --yes ### `copy` — Copy or retag an image -``` +```text hubmanager copy : : [options] +``` +```text Options: --src-registry URL Source registry (default: global --registry) --dst-registry URL Destination registry (default: global --registry) @@ -239,11 +275,13 @@ Options: ``` **Same-registry retag** — attempts cross-repo blob mount (no data transfer): + ```bash hubmanager copy myuser/myapp:v1.2.3 myuser/myapp:stable ``` **Cross-registry copy** — streams blobs from source to destination: + ```bash hubmanager copy myuser/myapp:latest \ --src-registry https://registry-1.docker.io \ @@ -252,6 +290,7 @@ hubmanager copy myuser/myapp:latest \ ``` **Copy specific platform** from a multi-arch image: + ```bash hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64 ``` @@ -260,7 +299,7 @@ hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64 ### `prune` — Delete outdated tags -``` +```text hubmanager prune [options] Options: @@ -300,7 +339,7 @@ hubmanager prune myuser/myapp --keep 1 --no-exclude --dry-run hubmanager automatically detects the authentication method by probing the registry's `/v2/` endpoint: | Registry type | Auth method | -|---|---| +| --- | --- | | Docker Hub | Bearer tokens via `auth.docker.io` | | Harbor, self-hosted with token server | Bearer tokens via registry-configured realm | | Basic-auth self-hosted | HTTP Basic Auth on every request | @@ -311,7 +350,7 @@ Bearer tokens are cached in memory for the duration of the session and refreshed ### Docker Hub notes - `list` uses the Docker Hub REST API (`hub.docker.com`) because the `_catalog` endpoint is restricted on Docker Hub. -- `delete` is not supported via the v2 API on Docker Hub. Use the web UI at https://hub.docker.com. +- `delete` is not supported via the v2 API on Docker Hub. Use the web UI at . - `prune` is not supported on Docker Hub for the same reason. --- @@ -341,7 +380,7 @@ hubmanager prune myapp --keep 3 --yes --json ## Exit Codes | Code | Meaning | -|------|---------| +| --- | --- | | 0 | Success | | 1 | General / usage error | | 2 | Authentication failure | @@ -380,12 +419,12 @@ hubmanager copy myuser/myapp:staging myuser/myapp:production ## Configuration reference | Key | Description | -|-----|-------------| +| --- | --- | | `REGISTRY` | Default registry URL | | `USERNAME` | Default username | -| `PASSWORD` | Default password or token | +| `PASSWORD` | Default password or token; prefix with `enc:` for encrypted values | | `REGISTRY__URL` | URL for a named registry alias | | `REGISTRY__USERNAME` | Username for a named alias | -| `REGISTRY__PASSWORD` | Password for a named alias | +| `REGISTRY__PASSWORD` | Password for a named alias (supports `enc:` prefix) | Aliases are case-insensitive and treat `-` as `_`. For example, alias `my-reg` maps to `REGISTRY_MY_REG_URL`. diff --git a/hubmanager b/hubmanager index 070ea94..1d2662e 100755 --- a/hubmanager +++ b/hubmanager @@ -1,6 +1,6 @@ #!/usr/bin/env bash # hubmanager - Manage Docker Registry images remotely -# Version: 0.1.0 +# Version: 0.2.0 # Dependencies: curl, jq, bash 4+ # Usage: hubmanager --help @@ -9,7 +9,7 @@ set -euo pipefail # ============================================================================= # --- Constants --- # ============================================================================= -readonly HM_VERSION="0.1.0" +readonly HM_VERSION="0.2.0" readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io" readonly HM_DEFAULT_CONFIG="${HOME}/.hubmanager.conf" readonly HM_DOCKERHUB_API="https://hub.docker.com" @@ -34,6 +34,7 @@ declare -A HM_TOKEN_EXPIRY=() # "registry|scope" -> epoch seconds declare -A HM_CONFIG_VARS=() # raw config key/value pairs HM_HUB_TOKEN="" # Docker Hub REST API token (JWT) +HM_MASTER_PASS="" # Master passphrase for encrypted config values (session-scoped) HM_TMPFILES=() HM_LAST_HTTP_CODE="" HM_LAST_HEADERS_FILE="" @@ -98,6 +99,66 @@ check_deps() { (( BASH_VERSINFO[0] >= 4 )) || die "Bash 4.0+ is required (found: $BASH_VERSION)." } +# ============================================================================= +# --- Encryption helpers --- +# ============================================================================= + +# Verify openssl is available (only needed when enc: values are present) +_require_openssl() { + command -v openssl &>/dev/null || \ + die "openssl is required for encrypted config values. Install openssl and retry." +} + +# Prompt once per session; result cached in HM_MASTER_PASS +_prompt_master_pass() { + [[ -n "$HM_MASTER_PASS" ]] && return 0 + _require_openssl + printf "hubmanager master passphrase: " >/dev/tty + read -rs HM_MASTER_PASS /dev/tty + [[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty." +} + +# Prompt and confirm a new master passphrase (used by login --encrypt) +_prompt_set_master_pass() { + _require_openssl + local pass1 pass2 + printf "New master passphrase: " >/dev/tty + read -rs pass1 /dev/tty + printf "Confirm master passphrase: " >/dev/tty + read -rs pass2 /dev/tty + [[ "$pass1" == "$pass2" ]] || die "Passphrases do not match." + [[ -n "$pass1" ]] || die "Master passphrase cannot be empty." + HM_MASTER_PASS="$pass1" +} + +# Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines) +_encrypt_value() { + local plaintext="$1" + _prompt_master_pass + local pass_file result + pass_file=$(mktemp); HM_TMPFILES+=("$pass_file") + printf '%s' "$HM_MASTER_PASS" > "$pass_file" + result=$(printf '%s' "$plaintext" | \ + openssl enc -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null | tr -d '\n') + [[ -n "$result" ]] || die "Encryption failed. Check that openssl is installed." + printf '%s' "$result" +} + +# Decrypt base64 CIPHERTEXT produced by _encrypt_value; outputs plaintext +_decrypt_value() { + local ciphertext="$1" + _prompt_master_pass + local pass_file result + pass_file=$(mktemp); HM_TMPFILES+=("$pass_file") + printf '%s' "$HM_MASTER_PASS" > "$pass_file" + if ! result=$(printf '%s' "$ciphertext" | \ + openssl enc -d -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null); then + die "Failed to decrypt config value. Wrong master passphrase?" + fi + printf '%s' "$result" +} + # ============================================================================= # --- Config loading --- # ============================================================================= @@ -138,7 +199,14 @@ load_config() { # Apply config defaults (CLI flags already set take precedence) [[ -z "$HM_REGISTRY" && -v 'HM_CONFIG_VARS[REGISTRY]' ]] && HM_REGISTRY="${HM_CONFIG_VARS[REGISTRY]}" [[ -z "$HM_USERNAME" && -v 'HM_CONFIG_VARS[USERNAME]' ]] && HM_USERNAME="${HM_CONFIG_VARS[USERNAME]}" - [[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]] && HM_PASSWORD="${HM_CONFIG_VARS[PASSWORD]}" + if [[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]]; then + local pw="${HM_CONFIG_VARS[PASSWORD]}" + if [[ "$pw" == enc:* ]]; then + HM_PASSWORD=$(_decrypt_value "${pw#enc:}") + else + HM_PASSWORD="$pw" + fi + fi } resolve_registry_alias() { @@ -153,7 +221,14 @@ resolve_registry_alias() { local pass_key="REGISTRY_${alias_upper}_PASSWORD" HM_REGISTRY="${HM_CONFIG_VARS[$url_key]}" [[ -z "$HM_USERNAME" && -v "HM_CONFIG_VARS[$user_key]" ]] && HM_USERNAME="${HM_CONFIG_VARS[$user_key]}" - [[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]] && HM_PASSWORD="${HM_CONFIG_VARS[$pass_key]}" + if [[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]]; then + local pw="${HM_CONFIG_VARS[$pass_key]}" + if [[ "$pw" == enc:* ]]; then + HM_PASSWORD=$(_decrypt_value "${pw#enc:}") + else + HM_PASSWORD="$pw" + fi + fi fi fi # Strip trailing slash @@ -537,7 +612,7 @@ ${C_BOLD}Commands:${C_RESET} delete : Delete an image tag or manifest copy Copy/retag an image prune Delete outdated tags for an image - login Test credentials and optionally save to config + login Test credentials and optionally save to config (--save [--encrypt]) Run ${C_BOLD}hubmanager --help${C_RESET} for command-specific options. EOF @@ -555,18 +630,24 @@ cmd_version() { echo "hubmanager $HM_VERSION"; } # ============================================================================= cmd_login() { local do_save=false + local do_encrypt=false while [[ $# -gt 0 ]]; do case "$1" in --save) do_save=true; shift ;; + --encrypt) do_encrypt=true; shift ;; -h|--help) cat <<'EOF' -Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] +Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] [--encrypt] Test credentials against the registry. Options: - --save Write validated credentials to the config file (~/.hubmanager.conf) + --save Write validated credentials to the config file (~/.hubmanager.conf) + --encrypt Encrypt the password in the config file with AES-256-CBC. + A master passphrase will be prompted; you must enter the same + passphrase on every subsequent hubmanager invocation that reads + the config file. EOF exit 0 ;; *) die "Unknown option: $1. Run 'hubmanager login --help'." ;; @@ -601,16 +682,28 @@ EOF if [[ "$do_save" == true ]]; then local config="$HM_CONFIG_FILE" + local pw_entry="" + if [[ -n "$HM_PASSWORD" ]]; then + if [[ "$do_encrypt" == true ]]; then + _prompt_set_master_pass + local ciphertext + ciphertext=$(_encrypt_value "$HM_PASSWORD") + pw_entry="PASSWORD=enc:${ciphertext}" + else + pw_entry="PASSWORD=${HM_PASSWORD}" + fi + fi { echo "# hubmanager configuration" echo "# Generated by: hubmanager login" echo "" echo "REGISTRY=${HM_REGISTRY}" [[ -n "$HM_USERNAME" ]] && echo "USERNAME=${HM_USERNAME}" - [[ -n "$HM_PASSWORD" ]] && echo "PASSWORD=${HM_PASSWORD}" + [[ -n "$pw_entry" ]] && echo "$pw_entry" } > "$config" chmod 600 "$config" info "Credentials saved to $config" + [[ "$do_encrypt" == true ]] && info "Password stored encrypted (AES-256-CBC). Master passphrase required on each use." fi }