From 242eeca238d7597bd06fd5184b63dc70c15fbe93 Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 1 Mar 2026 05:04:07 +0100 Subject: [PATCH] feat: add cross-invocation passphrase caching via Linux keyring (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 53 +++++++++++++++++++++++++- README.md | 66 ++++++++++++++++++++++++++++++++ hubmanager | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 221 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5c578ed..0ebd755 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` (default 300 / 5 min). +Also configurable via `CACHE_TIMEOUT=` 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) ### 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` | | `copy` | Copy/retag within or across registries; blob mount for same-registry retag | | `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 @@ -126,11 +167,12 @@ set -euo pipefail # --- Output / Formatting helpers --- # --- Dependency check --- # --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass() +# _keyring_get(), _keyring_set(), _keyring_clear() # --- Config loading --- # --- HTTP helpers --- raw_http(), get_response_header() # --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header() # --- 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() # --- Main dispatcher --- main() main "$@" @@ -184,6 +226,14 @@ main "$@" `HM_MASTER_PASS`). - `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 ` flag or `CACHE_TIMEOUT` config key. + --- ## Global Variables (key ones) @@ -199,6 +249,7 @@ main "$@" | `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_CACHE_TIMEOUT` | Keyring cache TTL in seconds (default 300) | | `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 70054fa..e969965 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub a - **curl** - **jq** - **openssl** *(optional — required only when using encrypted config values)* +- **keyctl** *(optional — from `keyutils`; enables passphrase caching across invocations)* ## Installation @@ -107,6 +108,33 @@ The `enc:` prefix also works for named alias passwords (`REGISTRY__PASSWO On every command that reads the config, the master passphrase is prompted once and cached 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 ```text @@ -117,6 +145,7 @@ hubmanager [OPTIONS] [COMMAND OPTIONS] -u, --user Username (overrides config file) -p, --password Password or token (overrides config file) --config Config file path (default: ~/.config/hubmanager.conf) + --cache-timeout Passphrase cache TTL in seconds (default: 300) --json Output raw JSON (pipe-friendly) --no-color Disable ANSI color -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 ```text @@ -442,6 +507,7 @@ hubmanager copy myuser/myapp:staging myuser/myapp:production | `REGISTRY` | Default registry URL | | `USERNAME` | Default username | | `PASSWORD` | Default password or token; prefix with `enc:` for encrypted values | +| `CACHE_TIMEOUT` | Passphrase keyring cache TTL in seconds (default: 300) | | `REGISTRY__URL` | URL for a named registry alias | | `REGISTRY__USERNAME` | Username for a named alias | | `REGISTRY__PASSWORD` | Password for a named alias (supports `enc:` prefix) | diff --git a/hubmanager b/hubmanager index 3a69e1f..7d8d564 100755 --- a/hubmanager +++ b/hubmanager @@ -1,6 +1,6 @@ #!/usr/bin/env bash # hubmanager - Manage Docker Registry images remotely -# Version: 0.2.1 +# Version: 0.3.0 # Dependencies: curl, jq, bash 4+ # Usage: hubmanager --help @@ -9,10 +9,12 @@ set -euo pipefail # ============================================================================= # --- 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_CONFIG="${HOME}/.config/hubmanager.conf" 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 --- @@ -35,6 +37,7 @@ 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_CACHE_TIMEOUT="$HM_KEYRING_DEFAULT_TIMEOUT" # Keyring cache TTL in seconds HM_TMPFILES=() HM_LAST_HTTP_CODE="" HM_LAST_HEADERS_FILE="" @@ -111,14 +114,54 @@ _require_openssl() { 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: [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() { [[ -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 printf "hubmanager master passphrase: " >/dev/tty read -rs HM_MASTER_PASS /dev/tty [[ -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) @@ -132,6 +175,7 @@ _prompt_set_master_pass() { [[ "$pass1" == "$pass2" ]] || die "Passphrases do not match." [[ -n "$pass1" ]] || die "Master passphrase cannot be empty." HM_MASTER_PASS="$pass1" + _keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT" } # Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines) @@ -208,6 +252,10 @@ load_config() { HM_PASSWORD="$pw" 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() { @@ -607,6 +655,7 @@ ${C_BOLD}Options:${C_RESET} -u, --user Username -p, --password Password or token --config Config file (default: ~/.config/hubmanager.conf) + --cache-timeout Passphrase cache TTL in seconds (default: 300) --json Output raw JSON --no-color Disable color output -v, --verbose Verbose mode @@ -622,6 +671,8 @@ ${C_BOLD}Commands:${C_RESET} copy Copy/retag an image prune Delete outdated tags for an image 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 --help${C_RESET} for command-specific options. EOF @@ -634,6 +685,50 @@ cmd_help() { show_usage; } # ============================================================================= cmd_version() { echo "hubmanager $HM_VERSION"; } +# ============================================================================= +# --- Subcommand: unlock --- +# ============================================================================= +cmd_unlock() { + if [[ "${1:-}" == "--help" ]]; then + cat <] + +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 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 + [[ -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 <