functionality for charsets added
This commit is contained in:
@@ -6,19 +6,28 @@ set -euo pipefail
|
||||
#
|
||||
# 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
|
||||
# himi Generate secret (default template) → clipboard
|
||||
# himi <template> Generate secret (named template) → clipboard
|
||||
# himi -o [template] Output to stdout + clipboard
|
||||
# himi -q [template] Output to stdout only (no clipboard)
|
||||
# himi -n N Generate N secrets
|
||||
# himi entropy [name] Show entropy for template(s)
|
||||
# himi -h Show help
|
||||
|
||||
readonly VERSION="1.0.0"
|
||||
readonly VERSION="1.1.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"
|
||||
readonly POOL_SUM_FILE="${POOL_DIR}/.pool.sha256"
|
||||
|
||||
# Bump when config.sh gains new options; configs declaring an older
|
||||
# CONFIG_VERSION (or none) get a nag until merged and re-stamped
|
||||
readonly CONFIG_SCHEMA_VERSION=2
|
||||
|
||||
# Generic charset token: {name:N} or {name:N-M}
|
||||
readonly TOKEN_RE='\{([a-z][a-z0-9_]*):([0-9]+)(-([0-9]+))?\}'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -27,10 +36,14 @@ readonly POOL_FILE="${POOL_DIR}/pool.txt"
|
||||
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='!?@#'
|
||||
SPECIAL_CHARSET='!?@#%~=+'
|
||||
WORD_MIN_LENGTH=4
|
||||
WORD_MAX_LENGTH=8
|
||||
|
||||
# Custom charsets: "name|characters". Names are lowercase [a-z][a-z0-9_]*.
|
||||
# Entries here override the built-in four (alpha, safe, num, special).
|
||||
CHARSETS=()
|
||||
|
||||
TEMPLATES=(
|
||||
"default|{word}-{word}-{alpha:5}"
|
||||
"easy|{word}-{alpha:3}-{num:3}"
|
||||
@@ -91,15 +104,106 @@ shuffle_lines() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Refuse to trust files that aren't ours or that others can write to
|
||||
# (ssh-config style check; the config file is sourced = code execution)
|
||||
# ------------------------------------------------------------------------------
|
||||
require_safe_file() {
|
||||
local f="$1"
|
||||
[[ -e "$f" ]] || return 0
|
||||
|
||||
if [[ ! -O "$f" ]]; then
|
||||
err "$f is not owned by you — refusing to use it"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local mode
|
||||
case "$PLATFORM" in
|
||||
macos) mode=$(stat -f '%Lp' "$f") ;;
|
||||
*) mode=$(stat -c '%a' "$f") ;;
|
||||
esac
|
||||
|
||||
if (( (8#$mode & 8#022) != 0 )); then
|
||||
err "$f is group/world-writable (mode $mode) — refusing to use it"
|
||||
err "fix with: chmod go-w '$f'"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Load user config if exists
|
||||
# ------------------------------------------------------------------------------
|
||||
load_config() {
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
require_safe_file "$CONFIG_DIR" || exit 1
|
||||
require_safe_file "$CONFIG_FILE" || exit 1
|
||||
source "$CONFIG_FILE"
|
||||
validate_charsets
|
||||
check_config_version
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Nag (don't fail) when the user's config predates the current schema —
|
||||
# new options like CHARSETS won't exist in it. Silenced by merging the new
|
||||
# default config and setting CONFIG_VERSION to the current schema version.
|
||||
# ------------------------------------------------------------------------------
|
||||
check_config_version() {
|
||||
local have="${CONFIG_VERSION:-0}"
|
||||
[[ "$have" =~ ^[0-9]+$ ]] || have=0
|
||||
|
||||
if (( have < CONFIG_SCHEMA_VERSION )); then
|
||||
warn "config.sh is from an older himi (config v${have} < v${CONFIG_SCHEMA_VERSION}) — new options are missing"
|
||||
warn "merge the repo's config.sh (or ${CONFIG_FILE}.new if the installer left one),"
|
||||
warn "then set CONFIG_VERSION=${CONFIG_SCHEMA_VERSION} in it to silence this"
|
||||
elif (( have > CONFIG_SCHEMA_VERSION )); then
|
||||
warn "config.sh declares v${have} but this himi understands v${CONFIG_SCHEMA_VERSION} — update the binary?"
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Sanity-check user-defined charsets (run once after config load)
|
||||
# ------------------------------------------------------------------------------
|
||||
validate_charsets() {
|
||||
local entry name set dupes
|
||||
for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
|
||||
IFS='|' read -r name set <<< "$entry"
|
||||
if [[ ! "$name" =~ ^[a-z][a-z0-9_]*$ ]]; then
|
||||
warn "charset '$name' ignored: names must match [a-z][a-z0-9_]*"
|
||||
continue
|
||||
fi
|
||||
if [[ -z "$set" ]]; then
|
||||
warn "charset '$name' is empty"
|
||||
continue
|
||||
fi
|
||||
dupes=$(printf '%s' "$set" | grep -o . | sort | uniq -d | tr -d '\n')
|
||||
if [[ -n "$dupes" ]]; then
|
||||
warn "charset '$name' has duplicate chars ($dupes): distribution skewed, entropy overstated"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Look up a charset by name: user CHARSETS first, then built-ins
|
||||
# ------------------------------------------------------------------------------
|
||||
get_charset() {
|
||||
local name="$1" entry cname cset
|
||||
for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
|
||||
IFS='|' read -r cname cset <<< "$entry"
|
||||
if [[ "$cname" == "$name" ]]; then
|
||||
printf '%s' "$cset"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
case "$name" in
|
||||
alpha) printf '%s' "$ALPHA_CHARSET" ;;
|
||||
safe) printf '%s' "$SAFE_CHARSET" ;;
|
||||
num) printf '%s' "$NUM_CHARSET" ;;
|
||||
special) printf '%s' "$SPECIAL_CHARSET" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Ensure directories exist
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -140,9 +244,15 @@ cmd_add() {
|
||||
|
||||
init_dirs
|
||||
|
||||
# Derive name from filename (strip path and extension)
|
||||
# Derive name from filename (strip path and extension, sanitize to [a-z0-9_-])
|
||||
local name
|
||||
name=$(basename "$file" | sed 's/\.[^.]*$//' | tr 'A-Z' 'a-z')
|
||||
name=$(basename "$file" | sed 's/\.[^.]*$//' | tr 'A-Z' 'a-z' | tr -cd 'a-z0-9_-')
|
||||
|
||||
if [[ -z "$name" ]]; then
|
||||
err "cannot derive a valid list name from '$file'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local listfile="${LISTS_DIR}/${name}.txt"
|
||||
|
||||
# Clean: lowercase, alpha only, sort, dedupe
|
||||
@@ -289,6 +399,39 @@ cmd_rebuild() {
|
||||
rebuild_pool
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Pool manifest: detect pool.txt changes made outside 'himi rebuild'
|
||||
# (catches accidental edits, sync corruption, casual tampering; an attacker
|
||||
# with write access to $HOME can defeat this, but they can also edit config.sh)
|
||||
# ------------------------------------------------------------------------------
|
||||
sha256_file() {
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum &>/dev/null; then
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
write_pool_manifest() {
|
||||
local sum
|
||||
sum=$(sha256_file "$POOL_FILE") || return 0 # no hash tool: skip quietly
|
||||
printf '%s\n' "$sum" > "$POOL_SUM_FILE"
|
||||
}
|
||||
|
||||
verify_pool_manifest() {
|
||||
[[ -f "$POOL_SUM_FILE" ]] || return 0 # pre-1.1 install: no manifest yet
|
||||
local want have
|
||||
want=$(<"$POOL_SUM_FILE")
|
||||
have=$(sha256_file "$POOL_FILE") || return 0
|
||||
if [[ "$want" != "$have" ]]; then
|
||||
err "pool.txt changed outside 'himi rebuild' (checksum mismatch)"
|
||||
warn "if this was you, run 'himi rebuild'; if not, inspect $POOL_DIR and $LISTS_DIR"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Rebuild pool from enabled lists (symlinks in pool/)
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -296,20 +439,25 @@ 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)
|
||||
# Only include symlinked files (enabled lists), exclude pool.txt itself.
|
||||
# find -exec is whitespace-safe (list names can contain '-' and '_' only,
|
||||
# but belt and suspenders).
|
||||
local enabled_count
|
||||
enabled_count=$(find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' 2>/dev/null | wc -l)
|
||||
|
||||
if [[ -z "$enabled_lists" ]]; then
|
||||
if (( enabled_count == 0 )); then
|
||||
warn "no lists enabled"
|
||||
> "$POOL_FILE"
|
||||
write_pool_manifest
|
||||
return
|
||||
fi
|
||||
|
||||
cat $enabled_lists \
|
||||
find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' -exec cat {} + \
|
||||
| filter_words "$WORD_MIN_LENGTH" "$WORD_MAX_LENGTH" \
|
||||
> "$POOL_FILE"
|
||||
|
||||
write_pool_manifest
|
||||
|
||||
local after
|
||||
after=$(wc -l < "$POOL_FILE")
|
||||
local diff=$((after - before))
|
||||
@@ -424,10 +572,20 @@ calc_log2() {
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Get charset length
|
||||
# Get charset length (characters, not bytes — multibyte-safe)
|
||||
# ------------------------------------------------------------------------------
|
||||
charset_len() {
|
||||
printf "%s" "$1" | wc -c
|
||||
local s="$1"
|
||||
printf '%s' "${#s}"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Count occurrences of a fixed token in a string (pure bash, no pipelines)
|
||||
# ------------------------------------------------------------------------------
|
||||
count_token() {
|
||||
local s="$1" t="$2" stripped
|
||||
stripped="${s//"$t"/}"
|
||||
printf '%d' $(( (${#s} - ${#stripped}) / ${#t} ))
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -446,8 +604,7 @@ calc_entropy() {
|
||||
# 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}"
|
||||
word_bang_count=$(count_token "$template" '{word!}')
|
||||
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!\}/}"
|
||||
@@ -455,8 +612,7 @@ calc_entropy() {
|
||||
|
||||
# {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}"
|
||||
Word_bang_count=$(count_token "$template" '{Word!}')
|
||||
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!\}/}"
|
||||
@@ -464,64 +620,42 @@ calc_entropy() {
|
||||
|
||||
# {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}"
|
||||
WORD_count=$(count_token "$template" '{WORD}')
|
||||
template="${template//\{WORD\}/}"
|
||||
|
||||
Word_count=$(grep -o '{Word}' <<< "$template" | wc -l)
|
||||
Word_count="${Word_count:-0}"
|
||||
Word_count=$(count_token "$template" '{Word}')
|
||||
template="${template//\{Word\}/}"
|
||||
|
||||
word_count=$(grep -o '{word}' <<< "$template" | wc -l)
|
||||
word_count="${word_count:-0}"
|
||||
word_count=$(count_token "$template" '{word}')
|
||||
|
||||
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
|
||||
# Generic charset tokens: {name:N} or {name:N-M}
|
||||
# Entropy = log2( sum of charset_size^L for L in N..M )
|
||||
while [[ "$template" =~ $TOKEN_RE ]]; do
|
||||
local token="${BASH_REMATCH[0]}"
|
||||
local cname="${BASH_REMATCH[1]}"
|
||||
local lmin="${BASH_REMATCH[2]}"
|
||||
local lmax="${BASH_REMATCH[4]:-${BASH_REMATCH[2]}}"
|
||||
lmin=$(( 10#$lmin ))
|
||||
lmax=$(( 10#$lmax ))
|
||||
|
||||
# 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\}/}"
|
||||
local charset
|
||||
if charset=$(get_charset "$cname") && (( lmin <= lmax )); then
|
||||
local cs_size bits
|
||||
cs_size=$(charset_len "$charset")
|
||||
bits=$(awk -v lmin="$lmin" -v lmax="$lmax" -v cs="$cs_size" 'BEGIN {
|
||||
t = 0
|
||||
for (l = lmin; l <= lmax; l++) t += cs ^ l
|
||||
printf "%.4f", log(t) / log(2)
|
||||
}')
|
||||
entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
|
||||
fi
|
||||
# Unknown charsets and bad ranges contribute 0 bits; generation will warn.
|
||||
template="${template/"$token"/}"
|
||||
done
|
||||
|
||||
printf "%.1f" "$entropy"
|
||||
@@ -535,13 +669,76 @@ pick_words() {
|
||||
shuffle_lines -n "$n" "$POOL_FILE"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Uniform random integer 0..n-1 from /dev/urandom
|
||||
# (replaces bash $RANDOM, which is a weak PRNG; modulo bias for the n=2,3
|
||||
# used here is ≤ 0.002% on 16 bits — negligible)
|
||||
# ------------------------------------------------------------------------------
|
||||
rand_int() {
|
||||
local n="$1" r
|
||||
r=$(od -An -N2 -tu2 /dev/urandom | tr -d ' \n')
|
||||
printf '%d' $(( r % n ))
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Generate random string from charset
|
||||
# Indexes into the charset with rejection sampling: uniform, no modulo bias,
|
||||
# no tr metacharacter pitfalls, no SIGPIPE games with pipefail.
|
||||
# ------------------------------------------------------------------------------
|
||||
rand_chars() {
|
||||
local charset="$1"
|
||||
local count="$2"
|
||||
LC_ALL=C tr -dc "$charset" < /dev/urandom 2>/dev/null | head -c "$count"
|
||||
local cs_len="${#charset}"
|
||||
local out="" limit b idx
|
||||
|
||||
if (( cs_len < 1 || cs_len > 256 )); then
|
||||
err "charset must have 1-256 characters (got $cs_len)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Largest multiple of cs_len that fits in a byte; reject bytes above it
|
||||
limit=$(( 256 / cs_len * cs_len ))
|
||||
|
||||
while (( ${#out} < count )); do
|
||||
for b in $(od -An -N64 -tu1 /dev/urandom); do
|
||||
if (( b < limit )); then
|
||||
idx=$(( b % cs_len ))
|
||||
out+="${charset:idx:1}"
|
||||
fi
|
||||
(( ${#out} >= count )) && break
|
||||
done
|
||||
done
|
||||
|
||||
printf '%s' "${out:0:count}"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Pick a length in [lmin, lmax], weighted by charset_size^L so that every
|
||||
# possible output string is equally likely (uniform length would skew toward
|
||||
# short strings and tank the min-entropy)
|
||||
# ------------------------------------------------------------------------------
|
||||
pick_length() {
|
||||
local lmin="$1" lmax="$2" cs_len="$3"
|
||||
|
||||
if (( lmin == lmax )); then
|
||||
printf '%s' "$lmin"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local r
|
||||
r=$(od -An -N4 -tu4 /dev/urandom | tr -d ' \n')
|
||||
|
||||
awk -v r="$r" -v lmin="$lmin" -v lmax="$lmax" -v cs="$cs_len" 'BEGIN {
|
||||
total = 0
|
||||
for (l = lmin; l <= lmax; l++) total += cs ^ l
|
||||
x = (r / 4294967296.0) * total
|
||||
acc = 0
|
||||
for (l = lmin; l <= lmax; l++) {
|
||||
acc += cs ^ l
|
||||
if (x < acc) { print l; exit }
|
||||
}
|
||||
print lmax
|
||||
}'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -568,71 +765,66 @@ expand_template() {
|
||||
# {word!} - random lower/UPPER (+1 bit)
|
||||
while [[ "$result" =~ \{word!\} ]]; do
|
||||
word=$(pick_words 1)
|
||||
if (( RANDOM % 2 )); then
|
||||
if (( $(rand_int 2) )); then
|
||||
word=$(printf '%s' "$word" | to_upper)
|
||||
fi
|
||||
result="${result/\{word!\}/$word}"
|
||||
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
|
||||
case "$(rand_int 3)" in
|
||||
1) word=$(printf '%s' "$word" | to_title) ;;
|
||||
2) word=$(printf '%s' "$word" | to_upper) ;;
|
||||
esac
|
||||
result="${result/\{Word!\}/$word}"
|
||||
result="${result/\{Word!\}/"$word"}"
|
||||
done
|
||||
|
||||
# {WORD} - all upper
|
||||
while [[ "$result" =~ \{WORD\} ]]; do
|
||||
word=$(pick_words 1 | to_upper)
|
||||
result="${result/\{WORD\}/$word}"
|
||||
result="${result/\{WORD\}/"$word"}"
|
||||
done
|
||||
|
||||
# {Word} - title case
|
||||
while [[ "$result" =~ \{Word\} ]]; do
|
||||
word=$(pick_words 1 | to_title)
|
||||
result="${result/\{Word\}/$word}"
|
||||
result="${result/\{Word\}/"$word"}"
|
||||
done
|
||||
|
||||
# {word} - all lower (default)
|
||||
while [[ "$result" =~ \{word\} ]]; do
|
||||
word=$(pick_words 1)
|
||||
result="${result/\{word\}/$word}"
|
||||
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}"
|
||||
# Generic charset tokens: {name:N} or {name:N-M}
|
||||
while [[ "$result" =~ $TOKEN_RE ]]; do
|
||||
local token="${BASH_REMATCH[0]}"
|
||||
local cname="${BASH_REMATCH[1]}"
|
||||
local lmin="${BASH_REMATCH[2]}"
|
||||
local lmax="${BASH_REMATCH[4]:-${BASH_REMATCH[2]}}"
|
||||
lmin=$(( 10#$lmin ))
|
||||
lmax=$(( 10#$lmax ))
|
||||
|
||||
local charset n chars
|
||||
if ! charset=$(get_charset "$cname") || (( lmin > lmax )); then
|
||||
# Unknown charset or bad range: neutralize the opening brace so the
|
||||
# loop can move on; restored below so the leftover-token warning fires.
|
||||
result="${result/"$token"/$'\x01'${token:1}}"
|
||||
continue
|
||||
fi
|
||||
|
||||
n=$(pick_length "$lmin" "$lmax" "$(charset_len "$charset")")
|
||||
chars=$(rand_chars "$charset" "$n")
|
||||
# Quoted replacement: keeps bash 5.2's patsub_replacement from
|
||||
# interpreting '&' in user charsets as "the matched text"
|
||||
result="${result/"$token"/"$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
|
||||
# Restore any neutralized tokens so they're visible (and warnable)
|
||||
result="${result//$'\x01'/\{}"
|
||||
|
||||
printf "%s" "$result"
|
||||
}
|
||||
@@ -655,6 +847,8 @@ get_template() {
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Copy to clipboard (cross-platform)
|
||||
# Consumes stdin in all cases; NEVER leaks the secret to stdout (the caller
|
||||
# decides what to print — this used to double-print on headless boxes)
|
||||
# ------------------------------------------------------------------------------
|
||||
to_clipboard() {
|
||||
case "$PLATFORM" in
|
||||
@@ -669,14 +863,12 @@ to_clipboard() {
|
||||
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"
|
||||
cat > /dev/null
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
cat # fallback: output to stdout
|
||||
warn "clipboard not supported on this platform"
|
||||
cat > /dev/null
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
@@ -758,11 +950,19 @@ cmd_test() {
|
||||
printf " ${BOLD}Entropy:${NC} ≈%s bits\n" "$entropy"
|
||||
printf "\n"
|
||||
printf " ${BOLD}Examples:${NC}\n"
|
||||
local example leftovers=""
|
||||
for ((i = 0; i < 5; i++)); do
|
||||
printf " %s\n" "$(expand_template "$pattern")"
|
||||
example=$(expand_template "$pattern")
|
||||
[[ "$example" == *'{'* && "$example" == *'}'* ]] && leftovers="true"
|
||||
printf " %s\n" "$example"
|
||||
done
|
||||
printf "\n"
|
||||
|
||||
if [[ -n "$leftovers" ]]; then
|
||||
warn "pattern contains unexpanded {token}s — unknown charset or typo? (0 bits counted for them)"
|
||||
printf "\n"
|
||||
fi
|
||||
|
||||
# Show comparison with built-in templates
|
||||
printf " ${DIM}%-12s %s${NC}\n" "TEMPLATE" "ENTROPY"
|
||||
printf " ${DIM}%-12s %s${NC}\n" "────────────" "───────"
|
||||
@@ -784,12 +984,16 @@ cmd_generate() {
|
||||
local template_name="${1:-$DEFAULT_TEMPLATE}"
|
||||
local count="${2:-1}"
|
||||
local use_stdout="${3:-false}"
|
||||
local use_clipboard="${4:-true}"
|
||||
|
||||
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
|
||||
err "pool empty — run 'himi rebuild' first"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Refuse if pool.txt was modified behind himi's back
|
||||
verify_pool_manifest || return 1
|
||||
|
||||
# Sanity check: ensure pool has enough unique words for meaningful entropy
|
||||
local unique_words
|
||||
unique_words=$(sort -u "$POOL_FILE" | wc -l)
|
||||
@@ -808,13 +1012,22 @@ cmd_generate() {
|
||||
secrets+=$(expand_template "$pattern")
|
||||
done
|
||||
|
||||
# Always copy to clipboard
|
||||
if printf "%s" "$secrets" | to_clipboard; then
|
||||
ok "copied to clipboard"
|
||||
if [[ "$secrets" == *'{'* && "$secrets" == *'}'* ]]; then
|
||||
warn "output contains unexpanded {token}s — check the template ('himi test' helps)"
|
||||
fi
|
||||
|
||||
# Also print to stdout if -o was passed
|
||||
if [[ "$use_stdout" == "true" ]]; then
|
||||
local copied="false"
|
||||
if [[ "$use_clipboard" == "true" ]]; then
|
||||
if printf "%s" "$secrets" | to_clipboard; then
|
||||
ok "copied to clipboard"
|
||||
copied="true"
|
||||
else
|
||||
warn "clipboard unavailable (install xclip, xsel, or wl-copy) — printing instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print if asked for stdout, or if the secret would otherwise be lost
|
||||
if [[ "$use_stdout" == "true" || "$copied" == "false" ]]; then
|
||||
printf "%s\n" "$secrets"
|
||||
fi
|
||||
}
|
||||
@@ -829,6 +1042,7 @@ himi v${VERSION} — memorable secret generator
|
||||
Usage:
|
||||
himi [template] Generate → clipboard (default: $DEFAULT_TEMPLATE)
|
||||
himi -o [template] Generate → clipboard + stdout
|
||||
himi -q [template] Generate → stdout only (no clipboard; or HIMI_NO_CLIP=1)
|
||||
himi -n N [template] Generate N secrets
|
||||
|
||||
himi add <file> Add and enable a word list
|
||||
@@ -858,10 +1072,24 @@ Tokens:
|
||||
{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
|
||||
{NAME:N} N chars from charset NAME
|
||||
{NAME:N-M} N to M chars from charset NAME (weighted so every
|
||||
output is equally likely; entropy = log2 of total outcomes)
|
||||
|
||||
Charsets:
|
||||
EOF
|
||||
printf " %-12s %s\n" "alpha" "$ALPHA_CHARSET"
|
||||
printf " %-12s %s\n" "safe" "$SAFE_CHARSET"
|
||||
printf " %-12s %s\n" "num" "$NUM_CHARSET"
|
||||
printf " %-12s %s\n" "special" "$SPECIAL_CHARSET"
|
||||
for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
|
||||
IFS='|' read -r name pattern <<< "$entry"
|
||||
printf " %-12s %s\n" "$name" "$pattern"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Custom charsets: add "name|chars" entries to CHARSETS in config
|
||||
|
||||
Config: $CONFIG_FILE
|
||||
Pool: $POOL_FILE
|
||||
@@ -888,15 +1116,22 @@ main() {
|
||||
|
||||
# 2. If it's not a command, parse generation flags and templates
|
||||
local use_stdout="false"
|
||||
local use_clipboard="true"
|
||||
local count=1
|
||||
local template_name=""
|
||||
|
||||
[[ -n "${HIMI_NO_CLIP:-}" ]] && use_clipboard="false"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o|--stdout)
|
||||
use_stdout="true"
|
||||
shift
|
||||
;;
|
||||
-q|--quiet)
|
||||
use_clipboard="false"
|
||||
shift
|
||||
;;
|
||||
-n|--number)
|
||||
if [[ -z "${2:-}" || ! "${2:-}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
err "-n requires a positive integer (got: '${2:-}')"
|
||||
@@ -926,7 +1161,7 @@ main() {
|
||||
done
|
||||
|
||||
[[ -z "$template_name" ]] && template_name="$DEFAULT_TEMPLATE"
|
||||
cmd_generate "$template_name" "$count" "$use_stdout"
|
||||
cmd_generate "$template_name" "$count" "$use_stdout" "$use_clipboard"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user