functionality for charsets added

This commit is contained in:
2026-06-10 00:58:23 -07:00
parent 7dc91ca752
commit b86289a348
5 changed files with 400 additions and 126 deletions
Vendored
BIN
View File
Binary file not shown.
+21
View File
@@ -18,6 +18,7 @@ himi # generate → clipboard
himi -o # generate → clipboard + stdout
himi -o secure # use 'secure' template → clipboard + stdout
himi -n 5 -o easy # generate 5 'easy' secrets → clipboard + stdout
himi -q # stdout only, skip clipboard (or HIMI_NO_CLIP=1)
himi test '{word}-{num:2}' # test a custom template
```
@@ -93,9 +94,29 @@ TEMPLATES=(
| `{safe:N}` | N chars, ultra-safe (no ambiguous chars) | ~4.6 bits/char |
| `{num:N}` | N digits | ~3.3 bits/char |
| `{special:N}` | N from `!?@#%~=+` | ~3.0 bits/char |
| `{NAME:N-M}` | N to M chars from charset NAME | log2(Σ size^L) |
\* Word entropy depends on pool size. Values shown assume all built-in lists enabled (~14k words, ~13.8 bits/word).
### Custom charsets
Add `"name|characters"` entries to `CHARSETS` in the config, then use `{name:N}` or `{name:N-M}` in any template:
```bash
CHARSETS=(
"hex|0123456789abcdef"
"b58|123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
)
```
Names must match `[a-z][a-z0-9_]*` and override the built-in four (alpha, safe, num, special). Avoid duplicate characters; himi warns if it sees them, since they skew the distribution and overstate entropy.
Range tokens pick the length weighted by `size^L`, so every possible output is equally likely. `{num:1-2}` covers 0-99 plus 0-9 (110 outcomes, ~6.8 bits), not a 50/50 coin flip on length, which would tank min-entropy.
### Integrity checks
`himi rebuild` records a SHA-256 of the pool; generation refuses if `pool.txt` was modified outside of himi (accidental edits, sync corruption, casual tampering). himi also refuses to source a config file that isn't owned by you or is group/world-writable, since the config is executed shell. Neither check stops an attacker who can already write to your home directory (nothing can), but they catch everything short of that.
## Entropy rules of thumb
| Bits | Strength | Use case |
+18 -4
View File
@@ -1,6 +1,10 @@
# secret config — source'd by the secret command
# Location: ~/.config/himi/config.sh
# Schema version — himi nags if this is older than what it expects.
# After merging new options into an existing config, bump this to match.
CONFIG_VERSION=2
# ------------------------------------------------------------------------------
# Character sets for random portions
# ------------------------------------------------------------------------------
@@ -11,6 +15,17 @@ SAFE_CHARSET='ACDEFGHJKMNPQRTWXY234679'
NUM_CHARSET='0123456789'
SPECIAL_CHARSET='!?@#%~=+'
# ------------------------------------------------------------------------------
# Custom charsets (optional)
# Format: "name|characters" — use as {name:N} or {name:N-M} in templates.
# Names: lowercase, matching [a-z][a-z0-9_]*. Entries here override the
# built-in four. Avoid duplicate characters (skews the distribution).
# ------------------------------------------------------------------------------
CHARSETS=(
# "hex|0123456789abcdef"
# "b58|123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
)
# ------------------------------------------------------------------------------
# Word filtering (applied when building pool.txt)
# ------------------------------------------------------------------------------
@@ -27,10 +42,9 @@ WORD_MAX_LENGTH=9
# {Word} — random word (Title Case)
# {word!} — random lower or UPPER (+1 bit entropy)
# {Word!} — random lower/Title/UPPER (+1.58 bits entropy)
# {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 characters from SPECIAL_CHARSET
# {NAME:N} — N chars from charset NAME (alpha/safe/num/special/custom)
# {NAME:N-M} — N to M chars, weighted so every output is equally likely
# (entropy = log2 of total outcomes, e.g. {num:1-2} ≈ 6.8 bits)
# ------------------------------------------------------------------------------
TEMPLATES=(
"default|{word!}{num:2}-{word!}{num:2}-{alpha:3}{num:2}"
+355 -120
View File
@@ -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 "$@"
+6 -2
View File
@@ -7,7 +7,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="${HOME}/.local/bin"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/himi"
BASH_COMPLETION_DIR="${XDG_DATA_HOME:-$HOME/.local/share/bash-completion/completions}"
BASH_COMPLETION_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions"
ZSH_COMPLETION_DIR="${HOME}/.zfunc"
# Define markers for removal later
@@ -89,8 +89,12 @@ install_config() {
if [[ ! -f "${CONFIG_DIR}/config.sh" ]]; then
cp "${SCRIPT_DIR}/config.sh" "${CONFIG_DIR}/"
ok "Installed default config to $CONFIG_DIR"
elif ! cmp -s "${SCRIPT_DIR}/config.sh" "${CONFIG_DIR}/config.sh"; then
cp "${SCRIPT_DIR}/config.sh" "${CONFIG_DIR}/config.sh.new"
warn "Kept your existing config; new default saved as config.sh.new"
warn "Merge what you want, then set CONFIG_VERSION to match (himi will nag until you do)"
else
dim "Config already exists, skipping.\n"
dim "Config already up to date, skipping.\n"
fi
else
err "config.sh missing from installer directory"