1 Commits
v0.2.1 ... main

Author SHA1 Message Date
242eeca238 feat: add cross-invocation passphrase caching via Linux keyring (v0.3.0)
Use keyctl (keyutils) to cache the master passphrase in the kernel keyring
with a configurable TTL (default 5 min). New unlock/lock subcommands for
manual cache control. keyctl is optional — silently skipped if not installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 05:04:07 +01:00
3 changed files with 221 additions and 6 deletions

View File

@@ -10,6 +10,45 @@ authentication detection (bearer token or HTTP basic auth).
--- ---
## What Changed (Session 2026-03-01b)
### Passphrase caching via Linux keyring (v0.3.0)
Added cross-invocation master passphrase caching using the Linux kernel keyring
(`keyctl` from `keyutils`). `keyctl` is an **optional** dependency — if not
installed, behaviour is unchanged (prompt every time).
**New functions** (Encryption helpers section):
| Function | Purpose |
| --- | --- |
| `_keyring_available()` | Returns 0 if `keyctl` is installed |
| `_keyring_get()` | Read cached passphrase from user keyring; return 1 if missing/expired |
| `_keyring_set(pass, ttl)` | Store passphrase in keyring with TTL |
| `_keyring_clear()` | Revoke the cached key |
**New subcommands**:
| Command | Purpose |
| --- | --- |
| `unlock` | Prompt for passphrase and cache in keyring (useful before scripting) |
| `lock` | Clear cached passphrase from keyring immediately |
**New global option**: `--cache-timeout <seconds>` (default 300 / 5 min).
Also configurable via `CACHE_TIMEOUT=<seconds>` in `hubmanager.conf`.
**New constants**: `HM_KEYRING_KEY`, `HM_KEYRING_DEFAULT_TIMEOUT`.
**New global variable**: `HM_CACHE_TIMEOUT`.
**Modified functions**:
- `_prompt_master_pass()` — checks keyring before prompting; stores after prompt
- `_prompt_set_master_pass()` — stores in keyring after confirmation
**Version bump**: `0.2.1``0.3.0`
---
## What Changed (Session 2026-03-01) ## What Changed (Session 2026-03-01)
### Bug fixes and improvements (v0.2.1) ### Bug fixes and improvements (v0.2.1)
@@ -105,6 +144,8 @@ Both `load_config()` and `resolve_registry_alias()` detect the prefix and call
| `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` | | `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` |
| `copy` | Copy/retag within or across registries; blob mount for same-registry retag | | `copy` | Copy/retag within or across registries; blob mount for same-registry retag |
| `prune` | Delete outdated tags sorted by image creation date | | `prune` | Delete outdated tags sorted by image creation date |
| `unlock` | Cache master passphrase in kernel keyring (requires `keyctl`) |
| `lock` | Clear cached master passphrase from keyring |
### Supporting files ### Supporting files
@@ -126,11 +167,12 @@ set -euo pipefail
# --- Output / Formatting helpers --- # --- Output / Formatting helpers ---
# --- Dependency check --- # --- Dependency check ---
# --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass() # --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass()
# _keyring_get(), _keyring_set(), _keyring_clear()
# --- Config loading --- # --- Config loading ---
# --- HTTP helpers --- raw_http(), get_response_header() # --- HTTP helpers --- raw_http(), get_response_header()
# --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header() # --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header()
# --- Registry request --- registry_request() ← main HTTP wrapper # --- Registry request --- registry_request() ← main HTTP wrapper
# --- Subcommands --- cmd_login/list/tags/inspect/delete/copy/prune # --- Subcommands --- cmd_login/unlock/lock/list/tags/inspect/delete/copy/prune
# --- Global arg parsing --- parse_global_args() # --- Global arg parsing --- parse_global_args()
# --- Main dispatcher --- main() # --- Main dispatcher --- main()
main "$@" main "$@"
@@ -184,6 +226,14 @@ main "$@"
`HM_MASTER_PASS`). `HM_MASTER_PASS`).
- `openssl` is an optional dependency: not checked at startup, only on first `enc:` encounter. - `openssl` is an optional dependency: not checked at startup, only on first `enc:` encounter.
10. **Passphrase caching** (v0.3.0):
- Uses Linux kernel keyring (`keyctl` from `keyutils`) to cache the master passphrase
across invocations with a configurable TTL (default 300s / 5 min).
- `_prompt_master_pass()` checks keyring before prompting; stores after prompt.
- `keyctl` is optional: if not installed, each invocation prompts as before (no errors).
- `unlock` pre-caches the passphrase; `lock` clears it immediately.
- TTL configurable via `--cache-timeout <seconds>` flag or `CACHE_TIMEOUT` config key.
--- ---
## Global Variables (key ones) ## Global Variables (key ones)
@@ -199,6 +249,7 @@ main "$@"
| `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token | | `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token |
| `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) | | `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) |
| `HM_MASTER_PASS` | Master passphrase for `enc:` config values (session-cached) | | `HM_MASTER_PASS` | Master passphrase for `enc:` config values (session-cached) |
| `HM_CACHE_TIMEOUT` | Keyring cache TTL in seconds (default 300) |
| `HM_LAST_HTTP_CODE` | HTTP status of most recent request | | `HM_LAST_HTTP_CODE` | HTTP status of most recent request |
| `HM_LAST_HEADERS_FILE` | Temp file path with response headers | | `HM_LAST_HEADERS_FILE` | Temp file path with response headers |
| `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` | | `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` |

View File

@@ -8,6 +8,7 @@ A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub a
- **curl** - **curl**
- **jq** - **jq**
- **openssl** *(optional — required only when using encrypted config values)* - **openssl** *(optional — required only when using encrypted config values)*
- **keyctl** *(optional — from `keyutils`; enables passphrase caching across invocations)*
## Installation ## Installation
@@ -107,6 +108,33 @@ The `enc:` prefix also works for named alias passwords (`REGISTRY_<ALIAS>_PASSWO
On every command that reads the config, the master passphrase is prompted once and cached On every command that reads the config, the master passphrase is prompted once and cached
for the duration of the session. for the duration of the session.
### Passphrase caching across invocations
If `keyctl` (from the `keyutils` package) is installed, the master passphrase is
automatically cached in the Linux kernel keyring for 5 minutes. Subsequent commands
within that window will not re-prompt.
```bash
# First command prompts for passphrase, caches it for 5 min
hubmanager list
# Runs without prompting (within cache window)
hubmanager tags myuser/myapp
# Pre-cache before a scripted batch
hubmanager unlock
hubmanager list && hubmanager tags myuser/myapp && hubmanager inspect myuser/myapp:latest
# Clear cache immediately
hubmanager lock
# Custom timeout (10 minutes)
hubmanager unlock --cache-timeout 600
```
If `keyctl` is not installed, passphrase caching is silently skipped — each invocation
prompts as before.
## Global Options ## Global Options
```text ```text
@@ -117,6 +145,7 @@ hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
-u, --user <username> Username (overrides config file) -u, --user <username> Username (overrides config file)
-p, --password <pass> Password or token (overrides config file) -p, --password <pass> Password or token (overrides config file)
--config <file> Config file path (default: ~/.config/hubmanager.conf) --config <file> Config file path (default: ~/.config/hubmanager.conf)
--cache-timeout <s> Passphrase cache TTL in seconds (default: 300)
--json Output raw JSON (pipe-friendly) --json Output raw JSON (pipe-friendly)
--no-color Disable ANSI color --no-color Disable ANSI color
-v, --verbose Show HTTP request details (with auth redacted) -v, --verbose Show HTTP request details (with auth redacted)
@@ -147,6 +176,42 @@ hubmanager login --registry https://registry.example.com \
--- ---
### `unlock` — Cache master passphrase
```text
hubmanager unlock [--cache-timeout SECONDS]
```
Prompts for the master passphrase and stores it in the Linux kernel keyring
for the configured timeout (default: 300 seconds / 5 minutes). Useful before
running a batch of commands. Requires `keyctl` (keyutils package).
```bash
hubmanager unlock
# hubmanager master passphrase: ****
# Passphrase cached for 300s.
# Custom timeout
hubmanager unlock --cache-timeout 600
```
---
### `lock` — Clear cached passphrase
```text
hubmanager lock
```
Immediately revokes the cached passphrase from the kernel keyring.
```bash
hubmanager lock
# Passphrase cache cleared.
```
---
### `list` — List repositories ### `list` — List repositories
```text ```text
@@ -442,6 +507,7 @@ hubmanager copy myuser/myapp:staging myuser/myapp:production
| `REGISTRY` | Default registry URL | | `REGISTRY` | Default registry URL |
| `USERNAME` | Default username | | `USERNAME` | Default username |
| `PASSWORD` | Default password or token; prefix with `enc:` for encrypted values | | `PASSWORD` | Default password or token; prefix with `enc:` for encrypted values |
| `CACHE_TIMEOUT` | Passphrase keyring cache TTL in seconds (default: 300) |
| `REGISTRY_<ALIAS>_URL` | URL for a named registry alias | | `REGISTRY_<ALIAS>_URL` | URL for a named registry alias |
| `REGISTRY_<ALIAS>_USERNAME` | Username for a named alias | | `REGISTRY_<ALIAS>_USERNAME` | Username for a named alias |
| `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias (supports `enc:` prefix) | | `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias (supports `enc:` prefix) |

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# hubmanager - Manage Docker Registry images remotely # hubmanager - Manage Docker Registry images remotely
# Version: 0.2.1 # Version: 0.3.0
# Dependencies: curl, jq, bash 4+ # Dependencies: curl, jq, bash 4+
# Usage: hubmanager --help # Usage: hubmanager --help
@@ -9,10 +9,12 @@ set -euo pipefail
# ============================================================================= # =============================================================================
# --- Constants --- # --- Constants ---
# ============================================================================= # =============================================================================
readonly HM_VERSION="0.2.1" readonly HM_VERSION="0.3.0"
readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io" readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io"
readonly HM_DEFAULT_CONFIG="${HOME}/.config/hubmanager.conf" readonly HM_DEFAULT_CONFIG="${HOME}/.config/hubmanager.conf"
readonly HM_DOCKERHUB_API="https://hub.docker.com" readonly HM_DOCKERHUB_API="https://hub.docker.com"
readonly HM_KEYRING_KEY="hubmanager:master"
readonly HM_KEYRING_DEFAULT_TIMEOUT=300 # seconds (5 minutes)
# ============================================================================= # =============================================================================
# --- Global state --- # --- Global state ---
@@ -35,6 +37,7 @@ declare -A HM_CONFIG_VARS=() # raw config key/value pairs
HM_HUB_TOKEN="" # Docker Hub REST API token (JWT) HM_HUB_TOKEN="" # Docker Hub REST API token (JWT)
HM_MASTER_PASS="" # Master passphrase for encrypted config values (session-scoped) HM_MASTER_PASS="" # Master passphrase for encrypted config values (session-scoped)
HM_CACHE_TIMEOUT="$HM_KEYRING_DEFAULT_TIMEOUT" # Keyring cache TTL in seconds
HM_TMPFILES=() HM_TMPFILES=()
HM_LAST_HTTP_CODE="" HM_LAST_HTTP_CODE=""
HM_LAST_HEADERS_FILE="" HM_LAST_HEADERS_FILE=""
@@ -111,14 +114,54 @@ _require_openssl() {
die "openssl is required for encrypted config values. Install openssl and retry." die "openssl is required for encrypted config values. Install openssl and retry."
} }
# Prompt once per session; result cached in HM_MASTER_PASS # --- Keyring helpers (optional; keyctl from keyutils) ---
# Check if keyctl is available
_keyring_available() { command -v keyctl &>/dev/null; }
# Try to read the cached passphrase from the user session keyring.
# Outputs the passphrase on stdout; returns 1 if not found or expired.
_keyring_get() {
_keyring_available || return 1
local key_id
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 1
keyctl pipe "$key_id" 2>/dev/null || return 1
}
# Store passphrase in the user session keyring with a TTL.
# Args: <passphrase> [timeout_seconds]
_keyring_set() {
_keyring_available || return 0
local pass="$1"
local ttl="${2:-$HM_CACHE_TIMEOUT}"
local key_id
key_id=$(keyctl add user "$HM_KEYRING_KEY" "$pass" @u 2>/dev/null) || return 0
keyctl timeout "$key_id" "$ttl" 2>/dev/null || true
}
# Revoke (clear) the cached passphrase from the keyring.
_keyring_clear() {
_keyring_available || return 0
local key_id
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 0
keyctl revoke "$key_id" 2>/dev/null || true
}
# Prompt once per session; result cached in HM_MASTER_PASS and keyring
_prompt_master_pass() { _prompt_master_pass() {
[[ -n "$HM_MASTER_PASS" ]] && return 0 [[ -n "$HM_MASTER_PASS" ]] && return 0
# Try keyring cache first
local cached
if cached=$(_keyring_get); then
HM_MASTER_PASS="$cached"
return 0
fi
_require_openssl _require_openssl
printf "hubmanager master passphrase: " >/dev/tty printf "hubmanager master passphrase: " >/dev/tty
read -rs HM_MASTER_PASS </dev/tty read -rs HM_MASTER_PASS </dev/tty
printf "\n" >/dev/tty printf "\n" >/dev/tty
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty." [[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
} }
# Prompt and confirm a new master passphrase (used by login --encrypt) # Prompt and confirm a new master passphrase (used by login --encrypt)
@@ -132,6 +175,7 @@ _prompt_set_master_pass() {
[[ "$pass1" == "$pass2" ]] || die "Passphrases do not match." [[ "$pass1" == "$pass2" ]] || die "Passphrases do not match."
[[ -n "$pass1" ]] || die "Master passphrase cannot be empty." [[ -n "$pass1" ]] || die "Master passphrase cannot be empty."
HM_MASTER_PASS="$pass1" HM_MASTER_PASS="$pass1"
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
} }
# Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines) # Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines)
@@ -208,6 +252,10 @@ load_config() {
HM_PASSWORD="$pw" HM_PASSWORD="$pw"
fi fi
fi fi
# Cache timeout (only override if not set via CLI)
if [[ "$HM_CACHE_TIMEOUT" == "$HM_KEYRING_DEFAULT_TIMEOUT" && -v 'HM_CONFIG_VARS[CACHE_TIMEOUT]' ]]; then
HM_CACHE_TIMEOUT="${HM_CONFIG_VARS[CACHE_TIMEOUT]}"
fi
} }
resolve_registry_alias() { resolve_registry_alias() {
@@ -607,6 +655,7 @@ ${C_BOLD}Options:${C_RESET}
-u, --user <username> Username -u, --user <username> Username
-p, --password <pass> Password or token -p, --password <pass> Password or token
--config <file> Config file (default: ~/.config/hubmanager.conf) --config <file> Config file (default: ~/.config/hubmanager.conf)
--cache-timeout <s> Passphrase cache TTL in seconds (default: 300)
--json Output raw JSON --json Output raw JSON
--no-color Disable color output --no-color Disable color output
-v, --verbose Verbose mode -v, --verbose Verbose mode
@@ -622,6 +671,8 @@ ${C_BOLD}Commands:${C_RESET}
copy <src> <dst> Copy/retag an image copy <src> <dst> Copy/retag an image
prune <image> Delete outdated tags for an image prune <image> Delete outdated tags for an image
login Test credentials and optionally save to config (--save [--encrypt]) login Test credentials and optionally save to config (--save [--encrypt])
unlock Cache master passphrase in kernel keyring (requires keyctl)
lock Clear cached master passphrase
Run ${C_BOLD}hubmanager <command> --help${C_RESET} for command-specific options. Run ${C_BOLD}hubmanager <command> --help${C_RESET} for command-specific options.
EOF EOF
@@ -634,6 +685,50 @@ cmd_help() { show_usage; }
# ============================================================================= # =============================================================================
cmd_version() { echo "hubmanager $HM_VERSION"; } cmd_version() { echo "hubmanager $HM_VERSION"; }
# =============================================================================
# --- Subcommand: unlock ---
# =============================================================================
cmd_unlock() {
if [[ "${1:-}" == "--help" ]]; then
cat <<EOF
${C_BOLD}Usage:${C_RESET} hubmanager unlock [--timeout <seconds>]
Cache the master passphrase in the Linux kernel keyring so subsequent
commands do not re-prompt. Requires ${C_BOLD}keyctl${C_RESET} (keyutils package).
--timeout <sec> Cache duration (default: ${HM_KEYRING_DEFAULT_TIMEOUT}s / 5 min)
EOF
return 0
fi
_keyring_available || die "keyctl (keyutils) is required for passphrase caching. Install keyutils and retry."
_require_openssl
# Force a fresh prompt even if already cached
HM_MASTER_PASS=""
printf "hubmanager master passphrase: " >/dev/tty
read -rs HM_MASTER_PASS </dev/tty
printf "\n" >/dev/tty
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
info "Passphrase cached for ${HM_CACHE_TIMEOUT}s."
}
# =============================================================================
# --- Subcommand: lock ---
# =============================================================================
cmd_lock() {
if [[ "${1:-}" == "--help" ]]; then
cat <<EOF
${C_BOLD}Usage:${C_RESET} hubmanager lock
Clear the cached master passphrase from the Linux kernel keyring.
EOF
return 0
fi
_keyring_clear
HM_MASTER_PASS=""
info "Passphrase cache cleared."
}
# ============================================================================= # =============================================================================
# --- Subcommand: login --- # --- Subcommand: login ---
# ============================================================================= # =============================================================================
@@ -1593,8 +1688,9 @@ parse_global_args() {
-r|--registry) HM_REGISTRY="$2"; shift 2 ;; -r|--registry) HM_REGISTRY="$2"; shift 2 ;;
-u|--user) HM_USERNAME="$2"; shift 2 ;; -u|--user) HM_USERNAME="$2"; shift 2 ;;
-p|--password) HM_PASSWORD="$2"; shift 2 ;; -p|--password) HM_PASSWORD="$2"; shift 2 ;;
--config) HM_CONFIG_FILE="$2"; shift 2 ;; --config) HM_CONFIG_FILE="$2"; shift 2 ;;
--json) HM_OPT_JSON=true; shift ;; --cache-timeout) HM_CACHE_TIMEOUT="$2"; shift 2 ;;
--json) HM_OPT_JSON=true; shift ;;
--no-color) HM_OPT_NO_COLOR=true; shift ;; --no-color) HM_OPT_NO_COLOR=true; shift ;;
-v|--verbose) HM_OPT_VERBOSE=true; shift ;; -v|--verbose) HM_OPT_VERBOSE=true; shift ;;
-q|--quiet) HM_OPT_QUIET=true; shift ;; -q|--quiet) HM_OPT_QUIET=true; shift ;;
@@ -1635,6 +1731,8 @@ main() {
copy) cmd_copy "$@" ;; copy) cmd_copy "$@" ;;
prune) cmd_prune "$@" ;; prune) cmd_prune "$@" ;;
login) cmd_login "$@" ;; login) cmd_login "$@" ;;
unlock) cmd_unlock "$@" ;;
lock) cmd_lock "$@" ;;
help|-h|--help) cmd_help ;; help|-h|--help) cmd_help ;;
version|--version) cmd_version ;; version|--version) cmd_version ;;
*) *)