migration
This commit is contained in:
@@ -0,0 +1,932 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# === himi ===
|
||||
# Memorable secret generator with configurable word lists and templates.
|
||||
#
|
||||
# Usage:
|
||||
# himi rebuild Rebuild pool from enabled lists
|
||||
# himi Generate secret (default template) → clipboard
|
||||
# himi <template> Generate secret (named template) → clipboard
|
||||
# himi -o [template] Output to stdout + clipboard
|
||||
# himi -n N Generate N secrets
|
||||
# himi -e [template] Show entropy for template(s)
|
||||
# himi -h Show help
|
||||
|
||||
readonly VERSION="1.0.0"
|
||||
readonly CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/himi"
|
||||
readonly CONFIG_FILE="${CONFIG_DIR}/config.sh"
|
||||
readonly LISTS_DIR="${CONFIG_DIR}/lists"
|
||||
readonly POOL_DIR="${CONFIG_DIR}/pool"
|
||||
readonly POOL_FILE="${POOL_DIR}/pool.txt"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Defaults (overridden by config.sh)
|
||||
# ------------------------------------------------------------------------------
|
||||
ALPHA_CHARSET='0123456789ABCDEFGHJKMNPQRSTVWXYZ' # Crockford Base32 (32 chars, 5 bits)
|
||||
SAFE_CHARSET='ACDEFGHJKMNPQRTWXY234679' # Ultra-safe (24 chars, 4.6 bits) - no B/8, 0/O, 1/I/L, 5/S
|
||||
NUM_CHARSET='0123456789'
|
||||
SPECIAL_CHARSET='!?@#'
|
||||
WORD_MIN_LENGTH=4
|
||||
WORD_MAX_LENGTH=8
|
||||
|
||||
TEMPLATES=(
|
||||
"default|{word}-{word}-{alpha:5}"
|
||||
"easy|{word}-{alpha:3}-{num:3}"
|
||||
"secure|{word}-{word}-{alpha:5}-{word}-{alpha:5}{special:1}"
|
||||
"pin|{num:6}"
|
||||
"diceware|{word}-{word}-{word}-{word}-{word}"
|
||||
)
|
||||
|
||||
DEFAULT_TEMPLATE="default"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Colors (disabled if not a terminal)
|
||||
# ------------------------------------------------------------------------------
|
||||
if [[ -t 1 ]]; then
|
||||
readonly RED='\033[0;31m'
|
||||
readonly GREEN='\033[0;32m'
|
||||
readonly YELLOW='\033[0;33m'
|
||||
readonly DIM='\033[0;90m'
|
||||
readonly BOLD='\033[1m'
|
||||
readonly NC='\033[0m'
|
||||
else
|
||||
readonly RED='' GREEN='' YELLOW='' DIM='' BOLD='' NC=''
|
||||
fi
|
||||
|
||||
# Output helpers
|
||||
err() { printf "${RED}error:${NC} %s\n" "$1" >&2; }
|
||||
ok() { printf "${GREEN}✓${NC} %s\n" "$1" >&2; }
|
||||
warn() { printf "${YELLOW}!${NC} %s\n" "$1" >&2; }
|
||||
dim() { printf "${DIM}%s${NC}" "$1"; }
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Platform detection
|
||||
# ------------------------------------------------------------------------------
|
||||
detect_platform() {
|
||||
case "$(uname -s)" in
|
||||
Darwin*) PLATFORM="macos" ;;
|
||||
Linux*) PLATFORM="linux" ;;
|
||||
*) PLATFORM="unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Cross-platform shuffle (requires GNU coreutils)
|
||||
# ------------------------------------------------------------------------------
|
||||
shuffle_lines() {
|
||||
if command -v shuf &>/dev/null; then
|
||||
shuf "$@"
|
||||
elif command -v gshuf &>/dev/null; then
|
||||
gshuf "$@"
|
||||
else
|
||||
err "GNU coreutils required"
|
||||
if [[ "$PLATFORM" == "macos" ]]; then
|
||||
printf " Install with: brew install coreutils\n" >&2
|
||||
else
|
||||
printf " Install with: sudo apt install coreutils (or equivalent)\n" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Load user config if exists
|
||||
# ------------------------------------------------------------------------------
|
||||
load_config() {
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
source "$CONFIG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Ensure directories exist
|
||||
# ------------------------------------------------------------------------------
|
||||
init_dirs() {
|
||||
mkdir -p "$LISTS_DIR" "$POOL_DIR"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Filter words: length range + de-duplicate derivatives
|
||||
# ------------------------------------------------------------------------------
|
||||
filter_words() {
|
||||
local min_len="${1:-$WORD_MIN_LENGTH}"
|
||||
local max_len="${2:-$WORD_MAX_LENGTH}"
|
||||
|
||||
grep -E "^[a-z]{${min_len},${max_len}}$" \
|
||||
| sort -u \
|
||||
| awk '{
|
||||
if (prev && index($0, prev) == 1 && length($0) - length(prev) <= 4) next
|
||||
prev = $0; print
|
||||
}'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Add: import and enable a local word list
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_add() {
|
||||
local file="${1:-}"
|
||||
|
||||
if [[ -z "$file" ]]; then
|
||||
err "usage: himi add <wordlist-file>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
err "file not found: $file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
init_dirs
|
||||
|
||||
# Derive name from filename (strip path and extension)
|
||||
local name
|
||||
name=$(basename "$file" | sed 's/\.[^.]*$//' | tr 'A-Z' 'a-z')
|
||||
local listfile="${LISTS_DIR}/${name}.txt"
|
||||
|
||||
# Clean: lowercase, alpha only, sort, dedupe
|
||||
tr 'A-Z' 'a-z' < "$file" \
|
||||
| grep -E '^[a-z]+$' \
|
||||
| sort -u \
|
||||
> "$listfile"
|
||||
|
||||
local count
|
||||
count=$(wc -l < "$listfile")
|
||||
ok "added $name ($count words)"
|
||||
|
||||
# Enable by default
|
||||
cmd_enable "$name"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Remove: delete list and disable it
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_remove() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
err "usage: himi remove <list>..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
init_dirs
|
||||
local had_errors=0
|
||||
|
||||
for name in "$@"; do
|
||||
local listfile="${LISTS_DIR}/${name}.txt"
|
||||
local linkfile="${POOL_DIR}/${name}.txt"
|
||||
|
||||
if [[ ! -f "$listfile" ]]; then
|
||||
warn "list '$name' not found"
|
||||
had_errors=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# Remove symlink if it's currently enabled
|
||||
[[ -L "$linkfile" ]] && rm "$linkfile"
|
||||
|
||||
# Remove the actual list
|
||||
rm "$listfile"
|
||||
ok "removed $name"
|
||||
done
|
||||
|
||||
rebuild_pool
|
||||
return $had_errors
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Enable: symlink list(s) into pool/
|
||||
# ------------------------------------------------------------------------------
|
||||
enable_one() {
|
||||
local name="$1"
|
||||
local listfile="${LISTS_DIR}/${name}.txt"
|
||||
local linkfile="${POOL_DIR}/${name}.txt"
|
||||
|
||||
if [[ ! -f "$listfile" ]]; then
|
||||
err "list '$name' not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -L "$linkfile" ]]; then
|
||||
warn "'$name' already enabled"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ln -s "../lists/${name}.txt" "$linkfile"
|
||||
ok "enabled $name"
|
||||
}
|
||||
|
||||
cmd_enable() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
err "usage: himi enable <list>... | all"
|
||||
return 1
|
||||
fi
|
||||
|
||||
init_dirs
|
||||
|
||||
local had_errors=0
|
||||
for name in "$@"; do
|
||||
if [[ "$name" == "all" ]]; then
|
||||
local f
|
||||
for f in "$LISTS_DIR"/*.txt; do
|
||||
[[ -f "$f" ]] || continue
|
||||
enable_one "$(basename "$f" .txt)" || had_errors=1
|
||||
done
|
||||
else
|
||||
enable_one "$name" || had_errors=1
|
||||
fi
|
||||
done
|
||||
|
||||
rebuild_pool
|
||||
return $had_errors
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Disable: remove symlink(s) from pool/
|
||||
# ------------------------------------------------------------------------------
|
||||
disable_one() {
|
||||
local name="$1"
|
||||
local linkfile="${POOL_DIR}/${name}.txt"
|
||||
|
||||
if [[ ! -L "$linkfile" ]]; then
|
||||
warn "'$name' not enabled"
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm "$linkfile"
|
||||
ok "disabled $name"
|
||||
}
|
||||
|
||||
cmd_disable() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
err "usage: himi disable <list>... | all"
|
||||
return 1
|
||||
fi
|
||||
|
||||
init_dirs
|
||||
|
||||
local had_errors=0
|
||||
for name in "$@"; do
|
||||
if [[ "$name" == "all" ]]; then
|
||||
local f
|
||||
for f in "$POOL_DIR"/*.txt; do
|
||||
[[ -L "$f" ]] || continue
|
||||
disable_one "$(basename "$f" .txt)" || had_errors=1
|
||||
done
|
||||
else
|
||||
disable_one "$name" || had_errors=1
|
||||
fi
|
||||
done
|
||||
|
||||
rebuild_pool
|
||||
return $had_errors
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Rebuild: regenerate pool.txt from enabled lists
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_rebuild() {
|
||||
init_dirs
|
||||
rebuild_pool
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Rebuild pool from enabled lists (symlinks in pool/)
|
||||
# ------------------------------------------------------------------------------
|
||||
rebuild_pool() {
|
||||
local before=0
|
||||
[[ -f "$POOL_FILE" ]] && before=$(wc -l < "$POOL_FILE")
|
||||
|
||||
# Only include symlinked files (enabled lists), exclude pool.txt itself
|
||||
local enabled_lists
|
||||
enabled_lists=$(find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' 2>/dev/null)
|
||||
|
||||
if [[ -z "$enabled_lists" ]]; then
|
||||
warn "no lists enabled"
|
||||
> "$POOL_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
cat $enabled_lists \
|
||||
| filter_words "$WORD_MIN_LENGTH" "$WORD_MAX_LENGTH" \
|
||||
> "$POOL_FILE"
|
||||
|
||||
local after
|
||||
after=$(wc -l < "$POOL_FILE")
|
||||
local diff=$((after - before))
|
||||
|
||||
if [[ $before -eq 0 ]]; then
|
||||
ok "pool: $after words $(dim "($(calc_log2 "$after") bits/word)")"
|
||||
elif [[ $diff -ne 0 ]]; then
|
||||
local sign="+"
|
||||
[[ $diff -lt 0 ]] && sign=""
|
||||
ok "pool: $after words $(dim "($(calc_log2 "$after") bits/word)") [${sign}${diff}]"
|
||||
fi
|
||||
|
||||
if [[ "$after" -gt 0 && "$after" -lt 256 ]]; then
|
||||
warn "pool has only $after words ($(calc_log2 "$after") bits/word) — secrets will be weak"
|
||||
warn "enable more lists with 'himi enable' or add lists with 'himi add'"
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# List: show all lists and enabled status
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_list() {
|
||||
init_dirs
|
||||
|
||||
if ! ls "${LISTS_DIR}"/*.txt &>/dev/null; then
|
||||
warn "no word lists found — run 'himi add <file>'"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf " %-16s %7s\n" "LIST" "WORDS"
|
||||
printf " %-16s %7s\n" "────────────────" "───────"
|
||||
|
||||
for f in "${LISTS_DIR}"/*.txt; do
|
||||
local name count marker
|
||||
name=$(basename "$f" .txt)
|
||||
count=$(wc -l < "$f")
|
||||
|
||||
if [[ -L "${POOL_DIR}/${name}.txt" ]]; then
|
||||
marker="${GREEN}●${NC}"
|
||||
else
|
||||
marker="${DIM}○${NC}"
|
||||
fi
|
||||
|
||||
printf " %b %-16s %7d\n" "$marker" "$name" "$count"
|
||||
done
|
||||
|
||||
printf " %-16s %7s\n" "────────────────" "───────"
|
||||
|
||||
if [[ -f "$POOL_FILE" ]]; then
|
||||
local pool_count
|
||||
pool_count=$(wc -l < "$POOL_FILE")
|
||||
if [[ "$pool_count" -gt 0 ]]; then
|
||||
printf " %-16s %7d ${DIM}(%.1f bits/word)${NC}\n" "pool" "$pool_count" "$(calc_log2 "$pool_count")"
|
||||
else
|
||||
printf " %-16s %7d ${DIM}(empty)${NC}\n" "pool" "0"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Uninstall: remove himi and all data
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_uninstall() {
|
||||
read -p "Are you sure you want to remove himi and all custom wordlists? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 1. Clean up Shell Profiles
|
||||
local profiles=(".bashrc" ".zshrc" ".bash_profile" ".profile")
|
||||
local start_marker="# >>> himi initialize >>>"
|
||||
local end_marker="# <<< himi initialize <<<"
|
||||
|
||||
for p in "${profiles[@]}"; do
|
||||
if [[ -f "$HOME/$p" ]]; then
|
||||
# Uses sed to delete everything between the markers (inclusive)
|
||||
if grep -q "$start_marker" "$HOME/$p"; then
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS requires the extension to be attached to the -i flag
|
||||
sed -i '.bak' "/$start_marker/,/$end_marker/d" "$HOME/$p"
|
||||
else
|
||||
# Linux/GNU
|
||||
sed -i.bak "/$start_marker/,/$end_marker/d" "$HOME/$p"
|
||||
fi
|
||||
ok "Cleaned up $p (backup created as $p.bak)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Remove completions
|
||||
rm -f "${HOME}/.zfunc/_himi"
|
||||
rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/himi"
|
||||
|
||||
# 3. Remove data and binary
|
||||
rm -rf "$CONFIG_DIR"
|
||||
ok "Removed configuration and wordlists."
|
||||
|
||||
# Final step: Binary deletes itself
|
||||
local self
|
||||
self=$(command -v himi)
|
||||
rm -f "$self"
|
||||
|
||||
ok "himi has been uninstalled."
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Calculate log base 2 using awk
|
||||
# ------------------------------------------------------------------------------
|
||||
calc_log2() {
|
||||
awk -v n="$1" 'BEGIN { printf "%.2f", log(n)/log(2) }'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Get charset length
|
||||
# ------------------------------------------------------------------------------
|
||||
charset_len() {
|
||||
printf "%s" "$1" | wc -c
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Calculate entropy for a template string
|
||||
# ------------------------------------------------------------------------------
|
||||
calc_entropy() {
|
||||
local template="$1"
|
||||
local entropy=0
|
||||
local pool_size word_bits
|
||||
|
||||
pool_size=$(wc -l < "$POOL_FILE" 2>/dev/null || echo 0)
|
||||
[[ "$pool_size" -eq 0 ]] && { echo "0"; return; }
|
||||
|
||||
word_bits=$(calc_log2 "$pool_size")
|
||||
|
||||
# Count word variants (most specific first)
|
||||
# {word!} = pool + 1 bit (2 cases)
|
||||
local word_bang_count
|
||||
word_bang_count=$(grep -o '{word!}' <<< "$template" | wc -l)
|
||||
word_bang_count="${word_bang_count:-0}"
|
||||
if [[ "$word_bang_count" -gt 0 ]]; then
|
||||
entropy=$(awk -v e="$entropy" -v w="$word_bang_count" -v b="$word_bits" 'BEGIN { print e + w * (b + 1) }')
|
||||
template="${template//\{word!\}/}"
|
||||
fi
|
||||
|
||||
# {Word!} = pool + log2(3) bits (3 cases)
|
||||
local Word_bang_count
|
||||
Word_bang_count=$(grep -o '{Word!}' <<< "$template" | wc -l)
|
||||
Word_bang_count="${Word_bang_count:-0}"
|
||||
if [[ "$Word_bang_count" -gt 0 ]]; then
|
||||
entropy=$(awk -v e="$entropy" -v w="$Word_bang_count" -v b="$word_bits" 'BEGIN { print e + w * (b + log(3)/log(2)) }')
|
||||
template="${template//\{Word!\}/}"
|
||||
fi
|
||||
|
||||
# {WORD}, {Word}, {word} = pool bits only (no case entropy)
|
||||
local WORD_count Word_count word_count
|
||||
WORD_count=$(grep -o '{WORD}' <<< "$template" | wc -l)
|
||||
WORD_count="${WORD_count:-0}"
|
||||
template="${template//\{WORD\}/}"
|
||||
|
||||
Word_count=$(grep -o '{Word}' <<< "$template" | wc -l)
|
||||
Word_count="${Word_count:-0}"
|
||||
template="${template//\{Word\}/}"
|
||||
|
||||
word_count=$(grep -o '{word}' <<< "$template" | wc -l)
|
||||
word_count="${word_count:-0}"
|
||||
|
||||
local total_plain_words=$(( WORD_count + Word_count + word_count ))
|
||||
if [[ "$total_plain_words" -gt 0 ]]; then
|
||||
entropy=$(awk -v e="$entropy" -v w="$total_plain_words" -v b="$word_bits" 'BEGIN { print e + (w * b) }')
|
||||
fi
|
||||
|
||||
# Count {alpha:N} - extract N and calculate
|
||||
while [[ "$template" =~ \{alpha:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local charset_size
|
||||
charset_size=$(charset_len "$ALPHA_CHARSET")
|
||||
local bits
|
||||
bits=$(awk -v n="$n" -v cs="$charset_size" 'BEGIN { printf "%.2f", n * (log(cs)/log(2)) }')
|
||||
entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
|
||||
template="${template/\{alpha:$n\}/}"
|
||||
done
|
||||
|
||||
# Count {num:N}
|
||||
while [[ "$template" =~ \{num:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local charset_size
|
||||
charset_size=$(charset_len "$NUM_CHARSET")
|
||||
local bits
|
||||
bits=$(awk -v n="$n" -v cs="$charset_size" 'BEGIN { printf "%.2f", n * (log(cs)/log(2)) }')
|
||||
entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
|
||||
template="${template/\{num:$n\}/}"
|
||||
done
|
||||
|
||||
# Count {special:N}
|
||||
while [[ "$template" =~ \{special:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local charset_size
|
||||
charset_size=$(charset_len "$SPECIAL_CHARSET")
|
||||
local bits
|
||||
bits=$(awk -v n="$n" -v cs="$charset_size" 'BEGIN { printf "%.2f", n * (log(cs)/log(2)) }')
|
||||
entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
|
||||
template="${template/\{special:$n\}/}"
|
||||
done
|
||||
|
||||
# Count {safe:N}
|
||||
while [[ "$template" =~ \{safe:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local charset_size
|
||||
charset_size=$(charset_len "$SAFE_CHARSET")
|
||||
local bits
|
||||
bits=$(awk -v n="$n" -v cs="$charset_size" 'BEGIN { printf "%.2f", n * (log(cs)/log(2)) }')
|
||||
entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
|
||||
template="${template/\{safe:$n\}/}"
|
||||
done
|
||||
|
||||
printf "%.1f" "$entropy"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Pick N random words from pool
|
||||
# ------------------------------------------------------------------------------
|
||||
pick_words() {
|
||||
local n="${1:-1}"
|
||||
shuffle_lines -n "$n" "$POOL_FILE"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Generate random string from charset
|
||||
# ------------------------------------------------------------------------------
|
||||
rand_chars() {
|
||||
local charset="$1"
|
||||
local count="$2"
|
||||
LC_ALL=C tr -dc "$charset" < /dev/urandom 2>/dev/null | head -c "$count"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Portable case conversion (works on bash 3.2/macOS)
|
||||
# ------------------------------------------------------------------------------
|
||||
to_upper() { tr '[:lower:]' '[:upper:]'; }
|
||||
to_lower() { tr '[:upper:]' '[:lower:]'; }
|
||||
to_title() {
|
||||
local w
|
||||
read -r w
|
||||
printf "%s%s" "$(printf '%s' "${w:0:1}" | to_upper)" "$(printf '%s' "${w:1}" | to_lower)"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Expand a template into a secret
|
||||
# ------------------------------------------------------------------------------
|
||||
expand_template() {
|
||||
local template="$1"
|
||||
local result="$template"
|
||||
local word
|
||||
|
||||
# Process word tokens (most specific first to avoid partial matches)
|
||||
|
||||
# {word!} - random lower/UPPER (+1 bit)
|
||||
while [[ "$result" =~ \{word!\} ]]; do
|
||||
word=$(pick_words 1)
|
||||
if (( RANDOM % 2 )); then
|
||||
word=$(printf '%s' "$word" | to_upper)
|
||||
fi
|
||||
result="${result/\{word!\}/$word}"
|
||||
done
|
||||
|
||||
# {Word!} - random lower/Title/UPPER (+1.58 bits)
|
||||
while [[ "$result" =~ \{Word!\} ]]; do
|
||||
word=$(pick_words 1)
|
||||
case $(( RANDOM % 3 )) in
|
||||
1) word=$(printf '%s' "$word" | to_title) ;;
|
||||
2) word=$(printf '%s' "$word" | to_upper) ;;
|
||||
esac
|
||||
result="${result/\{Word!\}/$word}"
|
||||
done
|
||||
|
||||
# {WORD} - all upper
|
||||
while [[ "$result" =~ \{WORD\} ]]; do
|
||||
word=$(pick_words 1 | to_upper)
|
||||
result="${result/\{WORD\}/$word}"
|
||||
done
|
||||
|
||||
# {Word} - title case
|
||||
while [[ "$result" =~ \{Word\} ]]; do
|
||||
word=$(pick_words 1 | to_title)
|
||||
result="${result/\{Word\}/$word}"
|
||||
done
|
||||
|
||||
# {word} - all lower (default)
|
||||
while [[ "$result" =~ \{word\} ]]; do
|
||||
word=$(pick_words 1)
|
||||
result="${result/\{word\}/$word}"
|
||||
done
|
||||
|
||||
# Replace {alpha:N}
|
||||
while [[ "$result" =~ \{alpha:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local chars
|
||||
chars=$(rand_chars "$ALPHA_CHARSET" "$n")
|
||||
result="${result/\{alpha:$n\}/$chars}"
|
||||
done
|
||||
|
||||
# Replace {num:N}
|
||||
while [[ "$result" =~ \{num:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local chars
|
||||
chars=$(rand_chars "$NUM_CHARSET" "$n")
|
||||
result="${result/\{num:$n\}/$chars}"
|
||||
done
|
||||
|
||||
# Replace {special:N}
|
||||
while [[ "$result" =~ \{special:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local chars
|
||||
chars=$(rand_chars "$SPECIAL_CHARSET" "$n")
|
||||
result="${result/\{special:$n\}/$chars}"
|
||||
done
|
||||
|
||||
# Replace {safe:N}
|
||||
while [[ "$result" =~ \{safe:([0-9]+)\} ]]; do
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
local chars
|
||||
chars=$(rand_chars "$SAFE_CHARSET" "$n")
|
||||
result="${result/\{safe:$n\}/$chars}"
|
||||
done
|
||||
|
||||
printf "%s" "$result"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Get template string by name
|
||||
# ------------------------------------------------------------------------------
|
||||
get_template() {
|
||||
local name="$1"
|
||||
for entry in "${TEMPLATES[@]}"; do
|
||||
IFS='|' read -r tname tpattern <<< "$entry"
|
||||
if [[ "$tname" == "$name" ]]; then
|
||||
printf "%s" "$tpattern"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
err "unknown template '$name'"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Copy to clipboard (cross-platform)
|
||||
# ------------------------------------------------------------------------------
|
||||
to_clipboard() {
|
||||
case "$PLATFORM" in
|
||||
macos)
|
||||
pbcopy
|
||||
;;
|
||||
linux)
|
||||
if command -v xclip &>/dev/null; then
|
||||
xclip -selection clipboard
|
||||
elif command -v xsel &>/dev/null; then
|
||||
xsel --clipboard --input
|
||||
elif command -v wl-copy &>/dev/null; then
|
||||
wl-copy
|
||||
else
|
||||
cat # fallback: output to stdout
|
||||
warn "install xclip, xsel, or wl-copy for clipboard"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
cat # fallback: output to stdout
|
||||
warn "clipboard not supported on this platform"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Show entropy for templates
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_entropy() {
|
||||
local specific="${1:-}"
|
||||
|
||||
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
|
||||
err "pool empty — run 'himi rebuild' first"
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf "%-12s %-40s %s\n" "TEMPLATE" "EXAMPLE" "ENTROPY"
|
||||
printf "%-12s %-40s %s\n" "--------" "-------" "-------"
|
||||
|
||||
for entry in "${TEMPLATES[@]}"; do
|
||||
IFS='|' read -r name pattern <<< "$entry"
|
||||
|
||||
# If specific template requested, skip others
|
||||
[[ -n "$specific" && "$name" != "$specific" ]] && continue
|
||||
|
||||
local example entropy
|
||||
example=$(expand_template "$pattern")
|
||||
entropy=$(calc_entropy "$pattern")
|
||||
|
||||
printf "%-12s %-40s ≈%s bits\n" "$name" "$example" "$entropy"
|
||||
done
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Config: open config file in editor
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_config() {
|
||||
local editor="${EDITOR:-${VISUAL:-vi}}"
|
||||
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
err "config not found: $CONFIG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local before after
|
||||
before=$(cksum "$CONFIG_FILE")
|
||||
"$editor" "$CONFIG_FILE"
|
||||
after=$(cksum "$CONFIG_FILE")
|
||||
|
||||
if [[ "$before" != "$after" ]]; then
|
||||
ok "config changed — rebuilding pool"
|
||||
load_config
|
||||
rebuild_pool
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Test: one-off pattern test with entropy comparison
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_test() {
|
||||
local pattern="${1:-}"
|
||||
|
||||
if [[ -z "$pattern" ]]; then
|
||||
err "usage: himi test '{pattern}'"
|
||||
printf "\n Example: himi test '{word!}-{num:2}-{word!}'\n"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
|
||||
err "pool empty — run 'himi rebuild' first"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local entropy
|
||||
entropy=$(calc_entropy "$pattern")
|
||||
|
||||
printf "\n"
|
||||
printf " ${BOLD}Pattern:${NC} %s\n" "$pattern"
|
||||
printf " ${BOLD}Entropy:${NC} ≈%s bits\n" "$entropy"
|
||||
printf "\n"
|
||||
printf " ${BOLD}Examples:${NC}\n"
|
||||
for ((i = 0; i < 5; i++)); do
|
||||
printf " %s\n" "$(expand_template "$pattern")"
|
||||
done
|
||||
printf "\n"
|
||||
|
||||
# Show comparison with built-in templates
|
||||
printf " ${DIM}%-12s %s${NC}\n" "TEMPLATE" "ENTROPY"
|
||||
printf " ${DIM}%-12s %s${NC}\n" "────────────" "───────"
|
||||
printf " ${BOLD}%-12s ≈%s bits${NC} ← your pattern\n" "(test)" "$entropy"
|
||||
|
||||
for entry in "${TEMPLATES[@]}"; do
|
||||
IFS='|' read -r tname tpattern <<< "$entry"
|
||||
local tent
|
||||
tent=$(calc_entropy "$tpattern")
|
||||
printf " %-12s ≈%s bits\n" "$tname" "$tent"
|
||||
done
|
||||
printf "\n"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Generate secrets
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_generate() {
|
||||
local template_name="${1:-$DEFAULT_TEMPLATE}"
|
||||
local count="${2:-1}"
|
||||
local use_stdout="${3:-false}"
|
||||
|
||||
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
|
||||
err "pool empty — run 'himi rebuild' first"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Sanity check: ensure pool has enough unique words for meaningful entropy
|
||||
local unique_words
|
||||
unique_words=$(sort -u "$POOL_FILE" | wc -l)
|
||||
if [[ "$unique_words" -lt 256 ]]; then
|
||||
err "pool has only $unique_words unique words — refusing to generate (need ≥256)"
|
||||
warn "run 'himi rebuild' or 'himi enable' to fix"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local pattern
|
||||
pattern=$(get_template "$template_name") || return 1
|
||||
|
||||
local secrets=""
|
||||
for ((i = 0; i < count; i++)); do
|
||||
[[ -n "$secrets" ]] && secrets+=$'\n'
|
||||
secrets+=$(expand_template "$pattern")
|
||||
done
|
||||
|
||||
# Always copy to clipboard
|
||||
if printf "%s" "$secrets" | to_clipboard; then
|
||||
ok "copied to clipboard"
|
||||
fi
|
||||
|
||||
# Also print to stdout if -o was passed
|
||||
if [[ "$use_stdout" == "true" ]]; then
|
||||
printf "%s\n" "$secrets"
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Show help
|
||||
# ------------------------------------------------------------------------------
|
||||
cmd_help() {
|
||||
cat <<EOF
|
||||
himi v${VERSION} — memorable secret generator
|
||||
|
||||
Usage:
|
||||
himi [template] Generate → clipboard (default: $DEFAULT_TEMPLATE)
|
||||
himi -o [template] Generate → clipboard + stdout
|
||||
himi -n N [template] Generate N secrets
|
||||
|
||||
himi add <file> Add and enable a word list
|
||||
himi enable <list>... Enable word list(s), or 'all'
|
||||
himi disable <list>... Disable word list(s), or 'all'
|
||||
himi list Show all lists and status
|
||||
himi rebuild Rebuild pool from enabled lists
|
||||
|
||||
himi entropy [name] Show entropy for template(s)
|
||||
himi test '{pattern}' Test a custom pattern
|
||||
himi config Edit config in \$EDITOR
|
||||
himi uninstall Remove himi and all data
|
||||
himi -h, --help Show this help
|
||||
|
||||
Templates (configure in $CONFIG_FILE):
|
||||
EOF
|
||||
for entry in "${TEMPLATES[@]}"; do
|
||||
IFS='|' read -r name pattern <<< "$entry"
|
||||
printf " %-12s %s\n" "$name" "$pattern"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Tokens:
|
||||
{word} Random word (lowercase)
|
||||
{WORD} Random word (UPPERCASE)
|
||||
{Word} Random word (Title Case)
|
||||
{word!} Random word (lower or UPPER, +1 bit)
|
||||
{Word!} Random word (lower/Title/UPPER, +1.58 bits)
|
||||
{alpha:N} N chars, Crockford Base32 (5.0 bits/char)
|
||||
{safe:N} N chars, ultra-safe (4.6 bits/char)
|
||||
{num:N} N digits (3.3 bits/char)
|
||||
{special:N} N from: $SPECIAL_CHARSET
|
||||
|
||||
Config: $CONFIG_FILE
|
||||
Pool: $POOL_FILE
|
||||
EOF
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Main
|
||||
# ------------------------------------------------------------------------------
|
||||
main() {
|
||||
detect_platform
|
||||
load_config
|
||||
init_dirs
|
||||
|
||||
# 1. Intercept top-level commands first
|
||||
case "${1:-}" in
|
||||
add|enable|disable|remove|rebuild|list|test|config|entropy|uninstall)
|
||||
local cmd="cmd_$1"
|
||||
shift
|
||||
"$cmd" "$@" # Pass all remaining arguments correctly
|
||||
return $?
|
||||
;;
|
||||
esac
|
||||
|
||||
# 2. If it's not a command, parse generation flags and templates
|
||||
local use_stdout="false"
|
||||
local count=1
|
||||
local template_name=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o|--stdout)
|
||||
use_stdout="true"
|
||||
shift
|
||||
;;
|
||||
-n|--number)
|
||||
if [[ -z "${2:-}" || ! "${2:-}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
err "-n requires a positive integer (got: '${2:-}')"
|
||||
return 1
|
||||
fi
|
||||
count="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help|help)
|
||||
cmd_help
|
||||
return 0
|
||||
;;
|
||||
-*)
|
||||
err "Unknown option: $1"
|
||||
cmd_help >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
if [[ -n "$template_name" ]]; then
|
||||
err "Unexpected argument: $1. Did you mean to use a command?"
|
||||
return 1
|
||||
fi
|
||||
template_name="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$template_name" ]] && template_name="$DEFAULT_TEMPLATE"
|
||||
cmd_generate "$template_name" "$count" "$use_stdout"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user