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 # generate → clipboard + stdout
himi -o secure # use 'secure' template → clipboard + stdout himi -o secure # use 'secure' template → clipboard + stdout
himi -n 5 -o easy # generate 5 'easy' secrets → 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 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 | | `{safe:N}` | N chars, ultra-safe (no ambiguous chars) | ~4.6 bits/char |
| `{num:N}` | N digits | ~3.3 bits/char | | `{num:N}` | N digits | ~3.3 bits/char |
| `{special:N}` | N from `!?@#%~=+` | ~3.0 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). \* 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 ## Entropy rules of thumb
| Bits | Strength | Use case | | Bits | Strength | Use case |
+18 -4
View File
@@ -1,6 +1,10 @@
# secret config — source'd by the secret command # secret config — source'd by the secret command
# Location: ~/.config/himi/config.sh # 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 # Character sets for random portions
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -11,6 +15,17 @@ SAFE_CHARSET='ACDEFGHJKMNPQRTWXY234679'
NUM_CHARSET='0123456789' NUM_CHARSET='0123456789'
SPECIAL_CHARSET='!?@#%~=+' 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) # Word filtering (applied when building pool.txt)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -27,10 +42,9 @@ WORD_MAX_LENGTH=9
# {Word} — random word (Title Case) # {Word} — random word (Title Case)
# {word!} — random lower or UPPER (+1 bit entropy) # {word!} — random lower or UPPER (+1 bit entropy)
# {Word!} — random lower/Title/UPPER (+1.58 bits entropy) # {Word!} — random lower/Title/UPPER (+1.58 bits entropy)
# {alpha:N} — N chars, Crockford Base32 (5.0 bits/char) # {NAME:N} — N chars from charset NAME (alpha/safe/num/special/custom)
# {safe:N} — N chars, ultra-safe (4.6 bits/char) # {NAME:N-M} — N to M chars, weighted so every output is equally likely
# {num:N} — N digits (3.3 bits/char) # (entropy = log2 of total outcomes, e.g. {num:1-2} ≈ 6.8 bits)
# {special:N} — N characters from SPECIAL_CHARSET
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
TEMPLATES=( TEMPLATES=(
"default|{word!}{num:2}-{word!}{num:2}-{alpha:3}{num:2}" "default|{word!}{num:2}-{word!}{num:2}-{alpha:3}{num:2}"
+349 -114
View File
@@ -9,16 +9,25 @@ set -euo pipefail
# himi Generate secret (default template) → clipboard # himi Generate secret (default template) → clipboard
# himi <template> Generate secret (named template) → clipboard # himi <template> Generate secret (named template) → clipboard
# himi -o [template] Output to stdout + clipboard # himi -o [template] Output to stdout + clipboard
# himi -q [template] Output to stdout only (no clipboard)
# himi -n N Generate N secrets # himi -n N Generate N secrets
# himi -e [template] Show entropy for template(s) # himi entropy [name] Show entropy for template(s)
# himi -h Show help # 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_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/himi"
readonly CONFIG_FILE="${CONFIG_DIR}/config.sh" readonly CONFIG_FILE="${CONFIG_DIR}/config.sh"
readonly LISTS_DIR="${CONFIG_DIR}/lists" readonly LISTS_DIR="${CONFIG_DIR}/lists"
readonly POOL_DIR="${CONFIG_DIR}/pool" readonly POOL_DIR="${CONFIG_DIR}/pool"
readonly POOL_FILE="${POOL_DIR}/pool.txt" 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) 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 SAFE_CHARSET='ACDEFGHJKMNPQRTWXY234679' # Ultra-safe (24 chars, 4.6 bits) - no B/8, 0/O, 1/I/L, 5/S
NUM_CHARSET='0123456789' NUM_CHARSET='0123456789'
SPECIAL_CHARSET='!?@#' SPECIAL_CHARSET='!?@#%~=+'
WORD_MIN_LENGTH=4 WORD_MIN_LENGTH=4
WORD_MAX_LENGTH=8 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=( TEMPLATES=(
"default|{word}-{word}-{alpha:5}" "default|{word}-{word}-{alpha:5}"
"easy|{word}-{alpha:3}-{num:3}" "easy|{word}-{alpha:3}-{num:3}"
@@ -91,15 +104,106 @@ shuffle_lines() {
fi 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 user config if exists
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_config() { load_config() {
if [[ -f "$CONFIG_FILE" ]]; then if [[ -f "$CONFIG_FILE" ]]; then
require_safe_file "$CONFIG_DIR" || exit 1
require_safe_file "$CONFIG_FILE" || exit 1
source "$CONFIG_FILE" source "$CONFIG_FILE"
validate_charsets
check_config_version
fi 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 # Ensure directories exist
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -140,9 +244,15 @@ cmd_add() {
init_dirs 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 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" local listfile="${LISTS_DIR}/${name}.txt"
# Clean: lowercase, alpha only, sort, dedupe # Clean: lowercase, alpha only, sort, dedupe
@@ -289,6 +399,39 @@ cmd_rebuild() {
rebuild_pool 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/) # Rebuild pool from enabled lists (symlinks in pool/)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -296,20 +439,25 @@ rebuild_pool() {
local before=0 local before=0
[[ -f "$POOL_FILE" ]] && before=$(wc -l < "$POOL_FILE") [[ -f "$POOL_FILE" ]] && before=$(wc -l < "$POOL_FILE")
# Only include symlinked files (enabled lists), exclude pool.txt itself # Only include symlinked files (enabled lists), exclude pool.txt itself.
local enabled_lists # find -exec is whitespace-safe (list names can contain '-' and '_' only,
enabled_lists=$(find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' 2>/dev/null) # 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" warn "no lists enabled"
> "$POOL_FILE" > "$POOL_FILE"
write_pool_manifest
return return
fi fi
cat $enabled_lists \ find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' -exec cat {} + \
| filter_words "$WORD_MIN_LENGTH" "$WORD_MAX_LENGTH" \ | filter_words "$WORD_MIN_LENGTH" "$WORD_MAX_LENGTH" \
> "$POOL_FILE" > "$POOL_FILE"
write_pool_manifest
local after local after
after=$(wc -l < "$POOL_FILE") after=$(wc -l < "$POOL_FILE")
local diff=$((after - before)) local diff=$((after - before))
@@ -424,10 +572,20 @@ calc_log2() {
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Get charset length # Get charset length (characters, not bytes — multibyte-safe)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
charset_len() { 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) # Count word variants (most specific first)
# {word!} = pool + 1 bit (2 cases) # {word!} = pool + 1 bit (2 cases)
local word_bang_count local word_bang_count
word_bang_count=$(grep -o '{word!}' <<< "$template" | wc -l) word_bang_count=$(count_token "$template" '{word!}')
word_bang_count="${word_bang_count:-0}"
if [[ "$word_bang_count" -gt 0 ]]; then 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) }') entropy=$(awk -v e="$entropy" -v w="$word_bang_count" -v b="$word_bits" 'BEGIN { print e + w * (b + 1) }')
template="${template//\{word!\}/}" template="${template//\{word!\}/}"
@@ -455,8 +612,7 @@ calc_entropy() {
# {Word!} = pool + log2(3) bits (3 cases) # {Word!} = pool + log2(3) bits (3 cases)
local Word_bang_count local Word_bang_count
Word_bang_count=$(grep -o '{Word!}' <<< "$template" | wc -l) Word_bang_count=$(count_token "$template" '{Word!}')
Word_bang_count="${Word_bang_count:-0}"
if [[ "$Word_bang_count" -gt 0 ]]; then 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)) }') 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!\}/}" template="${template//\{Word!\}/}"
@@ -464,64 +620,42 @@ calc_entropy() {
# {WORD}, {Word}, {word} = pool bits only (no case entropy) # {WORD}, {Word}, {word} = pool bits only (no case entropy)
local WORD_count Word_count word_count local WORD_count Word_count word_count
WORD_count=$(grep -o '{WORD}' <<< "$template" | wc -l) WORD_count=$(count_token "$template" '{WORD}')
WORD_count="${WORD_count:-0}"
template="${template//\{WORD\}/}" template="${template//\{WORD\}/}"
Word_count=$(grep -o '{Word}' <<< "$template" | wc -l) Word_count=$(count_token "$template" '{Word}')
Word_count="${Word_count:-0}"
template="${template//\{Word\}/}" template="${template//\{Word\}/}"
word_count=$(grep -o '{word}' <<< "$template" | wc -l) word_count=$(count_token "$template" '{word}')
word_count="${word_count:-0}"
local total_plain_words=$(( WORD_count + Word_count + word_count )) local total_plain_words=$(( WORD_count + Word_count + word_count ))
if [[ "$total_plain_words" -gt 0 ]]; then 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) }') entropy=$(awk -v e="$entropy" -v w="$total_plain_words" -v b="$word_bits" 'BEGIN { print e + (w * b) }')
fi fi
# Count {alpha:N} - extract N and calculate # Generic charset tokens: {name:N} or {name:N-M}
while [[ "$template" =~ \{alpha:([0-9]+)\} ]]; do # Entropy = log2( sum of charset_size^L for L in N..M )
local n="${BASH_REMATCH[1]}" while [[ "$template" =~ $TOKEN_RE ]]; do
local charset_size local token="${BASH_REMATCH[0]}"
charset_size=$(charset_len "$ALPHA_CHARSET") local cname="${BASH_REMATCH[1]}"
local bits local lmin="${BASH_REMATCH[2]}"
bits=$(awk -v n="$n" -v cs="$charset_size" 'BEGIN { printf "%.2f", n * (log(cs)/log(2)) }') local lmax="${BASH_REMATCH[4]:-${BASH_REMATCH[2]}}"
entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }') lmin=$(( 10#$lmin ))
template="${template/\{alpha:$n\}/}" lmax=$(( 10#$lmax ))
done
# Count {num:N} local charset
while [[ "$template" =~ \{num:([0-9]+)\} ]]; do if charset=$(get_charset "$cname") && (( lmin <= lmax )); then
local n="${BASH_REMATCH[1]}" local cs_size bits
local charset_size cs_size=$(charset_len "$charset")
charset_size=$(charset_len "$NUM_CHARSET") bits=$(awk -v lmin="$lmin" -v lmax="$lmax" -v cs="$cs_size" 'BEGIN {
local bits t = 0
bits=$(awk -v n="$n" -v cs="$charset_size" 'BEGIN { printf "%.2f", n * (log(cs)/log(2)) }') 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 }') entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
template="${template/\{num:$n\}/}" fi
done # Unknown charsets and bad ranges contribute 0 bits; generation will warn.
template="${template/"$token"/}"
# 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 done
printf "%.1f" "$entropy" printf "%.1f" "$entropy"
@@ -535,13 +669,76 @@ pick_words() {
shuffle_lines -n "$n" "$POOL_FILE" 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 # 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() { rand_chars() {
local charset="$1" local charset="$1"
local count="$2" 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) # {word!} - random lower/UPPER (+1 bit)
while [[ "$result" =~ \{word!\} ]]; do while [[ "$result" =~ \{word!\} ]]; do
word=$(pick_words 1) word=$(pick_words 1)
if (( RANDOM % 2 )); then if (( $(rand_int 2) )); then
word=$(printf '%s' "$word" | to_upper) word=$(printf '%s' "$word" | to_upper)
fi fi
result="${result/\{word!\}/$word}" result="${result/\{word!\}/"$word"}"
done done
# {Word!} - random lower/Title/UPPER (+1.58 bits) # {Word!} - random lower/Title/UPPER (+1.58 bits)
while [[ "$result" =~ \{Word!\} ]]; do while [[ "$result" =~ \{Word!\} ]]; do
word=$(pick_words 1) word=$(pick_words 1)
case $(( RANDOM % 3 )) in case "$(rand_int 3)" in
1) word=$(printf '%s' "$word" | to_title) ;; 1) word=$(printf '%s' "$word" | to_title) ;;
2) word=$(printf '%s' "$word" | to_upper) ;; 2) word=$(printf '%s' "$word" | to_upper) ;;
esac esac
result="${result/\{Word!\}/$word}" result="${result/\{Word!\}/"$word"}"
done done
# {WORD} - all upper # {WORD} - all upper
while [[ "$result" =~ \{WORD\} ]]; do while [[ "$result" =~ \{WORD\} ]]; do
word=$(pick_words 1 | to_upper) word=$(pick_words 1 | to_upper)
result="${result/\{WORD\}/$word}" result="${result/\{WORD\}/"$word"}"
done done
# {Word} - title case # {Word} - title case
while [[ "$result" =~ \{Word\} ]]; do while [[ "$result" =~ \{Word\} ]]; do
word=$(pick_words 1 | to_title) word=$(pick_words 1 | to_title)
result="${result/\{Word\}/$word}" result="${result/\{Word\}/"$word"}"
done done
# {word} - all lower (default) # {word} - all lower (default)
while [[ "$result" =~ \{word\} ]]; do while [[ "$result" =~ \{word\} ]]; do
word=$(pick_words 1) word=$(pick_words 1)
result="${result/\{word\}/$word}" result="${result/\{word\}/"$word"}"
done done
# Replace {alpha:N} # Generic charset tokens: {name:N} or {name:N-M}
while [[ "$result" =~ \{alpha:([0-9]+)\} ]]; do while [[ "$result" =~ $TOKEN_RE ]]; do
local n="${BASH_REMATCH[1]}" local token="${BASH_REMATCH[0]}"
local chars local cname="${BASH_REMATCH[1]}"
chars=$(rand_chars "$ALPHA_CHARSET" "$n") local lmin="${BASH_REMATCH[2]}"
result="${result/\{alpha:$n\}/$chars}" 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 done
# Replace {num:N} # Restore any neutralized tokens so they're visible (and warnable)
while [[ "$result" =~ \{num:([0-9]+)\} ]]; do result="${result//$'\x01'/\{}"
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" printf "%s" "$result"
} }
@@ -655,6 +847,8 @@ get_template() {
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Copy to clipboard (cross-platform) # 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() { to_clipboard() {
case "$PLATFORM" in case "$PLATFORM" in
@@ -669,14 +863,12 @@ to_clipboard() {
elif command -v wl-copy &>/dev/null; then elif command -v wl-copy &>/dev/null; then
wl-copy wl-copy
else else
cat # fallback: output to stdout cat > /dev/null
warn "install xclip, xsel, or wl-copy for clipboard"
return 1 return 1
fi fi
;; ;;
*) *)
cat # fallback: output to stdout cat > /dev/null
warn "clipboard not supported on this platform"
return 1 return 1
;; ;;
esac esac
@@ -758,11 +950,19 @@ cmd_test() {
printf " ${BOLD}Entropy:${NC} ≈%s bits\n" "$entropy" printf " ${BOLD}Entropy:${NC} ≈%s bits\n" "$entropy"
printf "\n" printf "\n"
printf " ${BOLD}Examples:${NC}\n" printf " ${BOLD}Examples:${NC}\n"
local example leftovers=""
for ((i = 0; i < 5; i++)); do 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 done
printf "\n" 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 # Show comparison with built-in templates
printf " ${DIM}%-12s %s${NC}\n" "TEMPLATE" "ENTROPY" printf " ${DIM}%-12s %s${NC}\n" "TEMPLATE" "ENTROPY"
printf " ${DIM}%-12s %s${NC}\n" "────────────" "───────" printf " ${DIM}%-12s %s${NC}\n" "────────────" "───────"
@@ -784,12 +984,16 @@ cmd_generate() {
local template_name="${1:-$DEFAULT_TEMPLATE}" local template_name="${1:-$DEFAULT_TEMPLATE}"
local count="${2:-1}" local count="${2:-1}"
local use_stdout="${3:-false}" local use_stdout="${3:-false}"
local use_clipboard="${4:-true}"
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
err "pool empty — run 'himi rebuild' first" err "pool empty — run 'himi rebuild' first"
return 1 return 1
fi 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 # Sanity check: ensure pool has enough unique words for meaningful entropy
local unique_words local unique_words
unique_words=$(sort -u "$POOL_FILE" | wc -l) unique_words=$(sort -u "$POOL_FILE" | wc -l)
@@ -808,13 +1012,22 @@ cmd_generate() {
secrets+=$(expand_template "$pattern") secrets+=$(expand_template "$pattern")
done done
# Always copy to clipboard if [[ "$secrets" == *'{'* && "$secrets" == *'}'* ]]; then
if printf "%s" "$secrets" | to_clipboard; then warn "output contains unexpanded {token}s — check the template ('himi test' helps)"
ok "copied to clipboard"
fi fi
# Also print to stdout if -o was passed local copied="false"
if [[ "$use_stdout" == "true" ]]; then 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" printf "%s\n" "$secrets"
fi fi
} }
@@ -829,6 +1042,7 @@ himi v${VERSION} — memorable secret generator
Usage: Usage:
himi [template] Generate → clipboard (default: $DEFAULT_TEMPLATE) himi [template] Generate → clipboard (default: $DEFAULT_TEMPLATE)
himi -o [template] Generate → clipboard + stdout 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 -n N [template] Generate N secrets
himi add <file> Add and enable a word list himi add <file> Add and enable a word list
@@ -858,10 +1072,24 @@ Tokens:
{Word} Random word (Title Case) {Word} Random word (Title Case)
{word!} Random word (lower or UPPER, +1 bit) {word!} Random word (lower or UPPER, +1 bit)
{Word!} Random word (lower/Title/UPPER, +1.58 bits) {Word!} Random word (lower/Title/UPPER, +1.58 bits)
{alpha:N} N chars, Crockford Base32 (5.0 bits/char) {NAME:N} N chars from charset NAME
{safe:N} N chars, ultra-safe (4.6 bits/char) {NAME:N-M} N to M chars from charset NAME (weighted so every
{num:N} N digits (3.3 bits/char) output is equally likely; entropy = log2 of total outcomes)
{special:N} N from: $SPECIAL_CHARSET
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 Config: $CONFIG_FILE
Pool: $POOL_FILE Pool: $POOL_FILE
@@ -888,15 +1116,22 @@ main() {
# 2. If it's not a command, parse generation flags and templates # 2. If it's not a command, parse generation flags and templates
local use_stdout="false" local use_stdout="false"
local use_clipboard="true"
local count=1 local count=1
local template_name="" local template_name=""
[[ -n "${HIMI_NO_CLIP:-}" ]] && use_clipboard="false"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-o|--stdout) -o|--stdout)
use_stdout="true" use_stdout="true"
shift shift
;; ;;
-q|--quiet)
use_clipboard="false"
shift
;;
-n|--number) -n|--number)
if [[ -z "${2:-}" || ! "${2:-}" =~ ^[1-9][0-9]*$ ]]; then if [[ -z "${2:-}" || ! "${2:-}" =~ ^[1-9][0-9]*$ ]]; then
err "-n requires a positive integer (got: '${2:-}')" err "-n requires a positive integer (got: '${2:-}')"
@@ -926,7 +1161,7 @@ main() {
done done
[[ -z "$template_name" ]] && template_name="$DEFAULT_TEMPLATE" [[ -z "$template_name" ]] && template_name="$DEFAULT_TEMPLATE"
cmd_generate "$template_name" "$count" "$use_stdout" cmd_generate "$template_name" "$count" "$use_stdout" "$use_clipboard"
} }
main "$@" main "$@"
+6 -2
View File
@@ -7,7 +7,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="${HOME}/.local/bin" BIN_DIR="${HOME}/.local/bin"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/himi" 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" ZSH_COMPLETION_DIR="${HOME}/.zfunc"
# Define markers for removal later # Define markers for removal later
@@ -89,8 +89,12 @@ install_config() {
if [[ ! -f "${CONFIG_DIR}/config.sh" ]]; then if [[ ! -f "${CONFIG_DIR}/config.sh" ]]; then
cp "${SCRIPT_DIR}/config.sh" "${CONFIG_DIR}/" cp "${SCRIPT_DIR}/config.sh" "${CONFIG_DIR}/"
ok "Installed default config to $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 else
dim "Config already exists, skipping.\n" dim "Config already up to date, skipping.\n"
fi fi
else else
err "config.sh missing from installer directory" err "config.sh missing from installer directory"