#!/bin/bash # genpw - Generate a random password and print it to stdout # # Usage: # genpw [-l|--length length] [-c|--charset charset] [-e|--exclude charset] [length] # genpw -h # # Options: # -l, --length Generate a password with [length] characters Default: 32 # -c, --charset `tr` charset used to generate the password Default: [:alnum:][:punct:] # -e, --exclude `tr` charset to exclude from the password Default: (None) # -h, --help Show help message _genpw() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="genpw" ;; esac # Default 32 characters (arbitrary "long" length) local length=32 # Generate from alphanumeric + OWASP password special characters: https://owasp.org/www-community/password-special-characters local digit='0123456789' # [:digit:] local upper='ABCDEFGHIJKLMNOPQRSTUVWXYZ' # [:upper:] local lower='abcdefghijklmnopqrstuvwxyz' # [:lower:] local alnum="$digit$upper$lower" # [:alnum:] local punct='!"#$%&'\''()*+,-./:;<=>?@[\]^_`{|}~' # [:punct:] # Using the full list of characters like this instead of ranges (e.g. a-z) makes it easier to exclude later local charset="$alnum$punct" # Will be set to a tr range of characters to exclude from the password local exclude='' # Helper functions for messages and help _show_help() { # Only underline if stdout is a terminal local s; [ -t 1 ] && s=$'\033[4m' local r; [ -t 1 ] && r=$'\033[24m' echo "NAME" echo " $SCRIPT_NAME - generate a random password" echo "SYNOPSIS" echo " $SCRIPT_NAME [-l|--length ${s}length${r}] [-c|--charset ${s}charset${r}] [-e|--exclude ${s}charset${r}] [${s}length${r}]" echo " $SCRIPT_NAME -h" echo "DESCRIPTION" echo " Prints a random password built from the given charset (minus any" echo " excluded characters) to stdout." echo "OPTIONS" echo " -l, --length Generate a password with ${s}length${r} characters. Default: $length" echo " -c, --charset Define the \`tr\` charset used to generate the password. Default: [:alnum:][:punct:]" echo " -e, --exclude Define the \`tr\` charset to exclude from the password. Default: (None)" echo " -h, --help Show this help message" echo "CAVEATS" echo " Reads from /dev/random for entropy. May block on low-entropy systems." echo "EXIT STATUS" echo " 0 Success" echo " 2 Usage error (missing flag value, unknown option)" echo " 5 Empty charset after applying exclusions" echo " 6 Invalid charset (malformed bracket expression or POSIX class)" } _error() { echo "[ERR][$SCRIPT_NAME] $*" >&2; } _warn() { echo "[WRN][$SCRIPT_NAME] $*" >&2; } # Escapes forward slashes and ampersands with a backslash, ensuring `sed` sees a literal string _escape_sed() { printf %s "$1" | sed 's/[\/&]/\\&/g' } # Prints all characters between (and including) a start and end # _expand_range b e -> bcde _expand_range() { # Expect start and end as separate arguments, but also support providing a range instead local start_char="$1" local end_char="$2" # Support a range as input (b-d) instead of separate start and end arguments (b d) if [ -z "$end_char" ]; then # If only one argument was provided but it's a range (has a hyphen), get start and end from the range if echo "$start_char" | grep -q '^[^-]-[^-]$'; then # check for dash surrounded by 1 non-dash character end_char="${start_char#*-}" # remove everything before the hyphen start_char="${start_char%-*}" # remove everything after the hyphen # Error out if there is no second argument and the first is not a hyphenated range else _error "Range expected (e.g. \"b-d\" or \"b d\") but only one argument provided" return 2 fi fi # Convert characters to their ASCII values in decimal local start_num; start_num="$(printf "%d" "'$start_char")" local end_num; end_num="$(printf "%d" "'$end_char")" # If the end of the range comes before the start, warn and exit early if [ "$end_num" -lt "$start_num" ]; then _warn "Range start is after end; nothing will be printed to stdout" return 3 fi # Exit early if not all requested characters are printable and single-byte if [ "$start_num" -lt 32 ] || [ "$start_num" -gt 126 ] || [ "$end_num" -lt 32 ] || [ "$end_num" -gt 126 ]; then _warn "$start_num or $end_num is outside of printable ASCII range ([32,126]); nothing will be printed to stdout" return 4 fi local result for ((i = start_num; i <= end_num; i++)); do # Append the next character using an octal escape sequence, given its decimal ASCII value result+=$(printf "\\%03o" "$i") # decimal -> octal, \{octal} -> character done printf %s "$result" } _expand_all_ranges() { # Walks the input left-to-right, expanding any A-B style character ranges # (A and B must be alphanumeric) in place. Non-range characters are passed # through verbatim. Concatenation-based instead of sed-based so octal # escapes emitted by _expand_range aren't reinterpreted as backreferences local input="$1" local out='' local i=0 local n="${#input}" local ch local next1 local next2 while [ "$i" -lt "$n" ]; do ch="${input:$i:1}" next1="${input:$((i+1)):1}" next2="${input:$((i+2)):1}" case "$ch$next1$next2" in [A-Za-z0-9]-[A-Za-z0-9]) out="$out$(_expand_range "$ch" "$next2")" i=$((i + 3)) ;; *) out="$out$ch" i=$((i + 1)) ;; esac done printf %s "$out" } # Expands bracket expressions `[...]` into their literal character contents # Contents may include: literal characters, A-B ranges, and POSIX classes # like `[:lower:]`. Unclosed brackets produce an error. Anything outside # brackets is passed through verbatim (top-level ranges/classes are handled # by _expand_all_ranges / _expand_posix on the result) _expand_brackets() { local input="$1" local out='' local i=0 local n="${#input}" local ch local inner local close local inner_expanded while [ "$i" -lt "$n" ]; do ch="${input:$i:1}" if [ "$ch" != '[' ]; then out="$out$ch" i=$((i + 1)) continue fi # `[:` is a POSIX class opener, not a bracket expression opener -- # leave it to _expand_posix at the top level. This also keeps the # trailing-`]` check below from mis-closing at the `:` that ends a # class (e.g. `[:digit:]` has a `]` of its own) if [ "${input:$((i+1)):1}" = ':' ]; then out="$out$ch" i=$((i + 1)) continue fi # Find the matching `]`. Inside the bracket we skip over `[:...:]` # subclasses so their `]` doesn't close us early inner='' close=-1 local j=$((i + 1)) while [ "$j" -lt "$n" ]; do local jch="${input:$j:1}" if [ "$jch" = '[' ] && [ "${input:$((j+1)):1}" = ':' ]; then # Skip past the POSIX subclass `[:class:]` as a unit local k=$((j + 2)) while [ "$k" -lt "$n" ]; do if [ "${input:$k:1}" = ':' ] && [ "${input:$((k+1)):1}" = ']' ]; then inner="$inner${input:$j:$((k + 2 - j))}" j=$((k + 2)) break fi k=$((k + 1)) done if [ "$k" -ge "$n" ]; then _error "Invalid charset '$input': unclosed POSIX class starting at position $j" return 6 fi continue fi if [ "$jch" = ']' ]; then close="$j" break fi inner="$inner$jch" j=$((j + 1)) done if [ "$close" -lt 0 ]; then _error "Invalid charset '$input': unclosed bracket expression starting at position $i" return 6 fi # Expand POSIX classes, then ranges, inside the bracket contents inner_expanded="$(_expand_posix "$inner")" inner_expanded="$(_expand_all_ranges "$inner_expanded")" out="$out$inner_expanded" i=$((close + 1)) done printf %s "$out" } # Validates charset syntax: any `[:` (POSIX class opener) must complete as # `[:digit:]`, `[:upper:]`, `[:lower:]`, `[:alpha:]`, `[:alnum:]`, or # `[:punct:]`. A bare `[` not followed by `:` is treated as a bracket # expression opener and must close with `]`. Returns 0 if valid, # non-zero (with an error message) otherwise _validate_charset() { local input="$1" # Strip recognized POSIX classes; any remaining `[:` indicates an # unclosed or unrecognized class opener local stripped; stripped="$(printf %s "$input" | sed ' s/\[:digit:\]//g; s/\[:upper:\]//g; s/\[:lower:\]//g; s/\[:alpha:\]//g; s/\[:alnum:\]//g; s/\[:punct:\]//g; ')" case "$stripped" in *\[:*) _error "Invalid charset '$input': unclosed or unrecognized bracket expression (expected [:alnum:], [:alpha:], [:digit:], [:lower:], [:upper:], or [:punct:])" return 6 ;; esac return 0 } # Expands POSIX bracket expressions, e.g. [:digit:] -> 0123456789 _expand_posix() { printf %s "$*" | sed " # Leaving out [:space:] and [:cntrl:], which shouldn't be relevant here s/\[:digit:\]/$digit/g; s/\[:upper:\]/$upper/g; s/\[:lower:\]/$lower/g; s/\[:alpha:\]/$upper$lower/g; s/\[:alnum:\]/$digit$upper$lower/g; s/\[:punct:\]/$(_escape_sed "$punct")/g;" } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } # Clean up functions to avoid polluting global namespace _expand_short_opts "lce" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED # Handle arguments while [ $# -gt 0 ]; do case "$1" in -l|--length) [ "$2" ] || { _error "$1 specified but no length provided. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } length="$2" shift 2 ;; --length=*) length="${1#*=}" [ -n "$length" ] || { _error "--length specified but no length provided. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -c|--charset) [ "$2" ] || { _error "$1 specified but no charset provided. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } charset="$2" shift 2 ;; --charset=*) charset="${1#*=}" [ -n "$charset" ] || { _error "--charset specified but no charset provided. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -e|--exclude) [ "$2" ] || { _error "$1 specified but no charset provided. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } exclude="$2" shift 2 ;; --exclude=*) exclude="${1#*=}" [ -n "$exclude" ] || { _error "--exclude specified but no charset provided. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -h|--help) _show_help return 0 ;; ""|*[!0-9]*) # Not an --option or a number _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage" return 2 ;; *) # Must be a number length="$1" shift ;; esac done # Validate charset syntax first (catches unclosed/unrecognized bracket expressions) _validate_charset "$charset" || return $? [ -z "$exclude" ] || _validate_charset "$exclude" || return $? # Expansion pipeline: bracket expressions first (they internally handle # POSIX classes and ranges inside their `[...]`), then top-level POSIX # classes and ranges on whatever's outside the brackets. Finally, strip # any excluded characters local expanded_charset; expanded_charset="$(_expand_brackets "$charset")" || return $? local expanded_exclude; expanded_exclude="$(_expand_brackets "$exclude")" || return $? charset="$(_expand_posix "$(_expand_all_ranges "$expanded_charset")" | tr -d "$(_expand_posix "$(_expand_all_ranges "$expanded_exclude")")")" # Exit early if charset is empty after applying exclusions [ "$charset" ] || { _error "Charset is empty after exclusions"; return 5; } # Generate the password in a loop to ensure the length is exactly right local password="" while [ "${#password}" -lt "$length" ]; do # Generate a batch of characters and append them to $password # Setting locale via LC_ALL=C instructs tr to process input as single bytes instead of potentially-multi-byte UTF-8 password="$password$(LC_ALL=C tr -dc "$charset"