fix(genpass): improve performance and usability and fix bugs (#9520)

*Bugs*

The following bugs have been fixed:

- All generators ignored errors from external commands. For example,
  if `/usr/share/dict/words` was unreadable, `genpass-xkcd` would
  print "0-" as a password and return success.
- All generators silently ignored the argument if it wasn't a number.
  For example, `genpass-apple -2` was generating one password and
  not printing any errors.
- All generators silently ignored extra arguments. For example,
  `genpass-apple -n 2` was generating one password and not printing
  any errors.
- `genpass-xkcd` was generating passwords with less than 128 bits of
  security margin in contradiction to documentation. The smaller the
  dictionary size, the weaker the passwords it was generating. For a
  dictionary with 27 words, `genpass-xkcd` was generating passwords
  with 93 bits of security margin (`log2(27!)`).
- The source of random data used by `genpass-xkcd` was not
  cryptographically secure in contradiction to documentation. See:
  https://www.gnu.org/software/coreutils/manual/html_node/Random-sources.html
- `genpass-apple` could generate a password with non-ascii characters
  depending on user locale. For example, passwords could contain 'İ'
  for users with Turkish locale.
- `genpass-apple` didn't work with `ksh_arrays` shell option.
- `genpass-xkcd` was printing spurious errors with `ksh_arrays` shell
  option.
- `genpass-xkcd` was producing too short (weak) or too strong (long)
  and/or printing errors when `IFS` was set to non-default value.
- All generators were printing fewer passwords than requested and
  returning success when passed a very large number as an argument.

*Usability*

Generators are now implemented as self-contained executable files.
They can be invoked from scripts with no additional setup.

Generators no longer depend on external commands. The only dependencies
are `/dev/urandom` and, for `genpass-xkcd`, `/usr/share/dict/words`.

All generators used to silently ignore all arguments after the first
and the first argument if it wasn't a number. For example, both
`genpass-apple -2` and `genpass-apple -n 2` were generating one password
and not printing any errors. Now these print an error and fail.

*Performance*

The time it takes to load the plugin has been greatly reduced. This
translates into faster zsh startup when the plugin is enabled.

Incidentally, two generators out of three have been sped up to a large
degree while one generator (`genpass-xkcd`) has gotten slower. This is
unlikely to matter one way or another unless generating a very large
number of passwords. In the latter case `genpass-xkcd` is now also
faster than it used to be.

The following table shows benchmark results from Linux x86-64 on i9-7900X.
The numbers in the second and third columns show how many times a given
command could be executed per second. Higher numbers are better.

command                     | before (Hz) | after (Hz) | speedup |
----------------------------|------------:|-----------:|--------:|
`source genpass.plugin.zsh` |        4810 |      68700 |  +1326% |
`genpass-apple`             |        30.3 |        893 |  +2846% |
`genpass-monkey`            |         203 |       5290 |  +2504% |
`genpass-xkcd`              |        34.4 |       14.5 |    -58% |
`genpass-xkcd 1000`         |       0.145 |      0.804 |   +454% |
This commit is contained in:
Roman Perepelitsa 2020-12-16 16:57:59 +01:00 committed by GitHub
parent f2a4b2b17b
commit b28665aebb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 189 additions and 114 deletions

View File

@ -5,21 +5,22 @@ has at least a 128-bit security margin and generates passwords from the
cryptographically secure `/dev/urandom`. Each generator can also take an
optional numeric argument to generate multiple passwords.
Requirements:
* `grep(1)`
* GNU coreutils (or appropriate for your system)
* Word list providing `/usr/share/dict/words`
To use it, add `genpass` to the plugins array in your zshrc file:
To use it from an interactive ZSH, add `genpass` to the plugins array in your
zshrc file:
plugins=(... genpass)
You can also invoke password generators directly (they are implemented as
standalone executable files), which can be handy when you need to generate
passwords in a script:
~/.oh-my-zsh/plugins/genpass/genpass-apple 3
## genpass-apple
Generates a pronounceable pseudoword passphrase of the "cvccvc" consonant/vowel
syntax, inspired by [Apple's iCloud Keychain password generator][1]. Each
pseudoword has exactly 1 digit placed at the edge of a "word" and exactly 1
password has exactly 1 digit placed at the edge of a "word" and exactly 1
capital letter to satisfy most password security requirements.
% genpass-apple

79
plugins/genpass/genpass-apple Executable file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env zsh
#
# Usage: genpass-apple [NUM]
#
# Generate a password made of 6 pseudowords of 6 characters each
# with the security margin of at least 128 bits.
#
# Example password: xudmec-4ambyj-tavric-mumpub-mydVop-bypjyp
#
# If given a numerical argument, generate that many passwords.
emulate -L zsh -o no_unset -o warn_create_global -o warn_nested_var
if [[ ARGC -gt 1 || ${1-1} != ${~:-<1-$((16#7FFFFFFF))>} ]]; then
print -ru2 -- "usage: $0 [NUM]"
return 1
fi
zmodload zsh/system zsh/mathfunc || return
{
local -r vowels=aeiouy
local -r consonants=bcdfghjklmnpqrstvwxz
local -r digits=0123456789
# Sets REPLY to a uniformly distributed random number in [1, $1].
# Requires: $1 <= 256.
function -$0-rand() {
local c
while true; do
sysread -s1 c || return
# Avoid bias towards smaller numbers.
(( #c < 256 / $1 * $1 )) && break
done
typeset -g REPLY=$((#c % $1 + 1))
}
local REPLY chars
repeat ${1-1}; do
# Generate 6 pseudowords of the form cvccvc where c and v
# denote random consonants and vowels respectively.
local words=()
repeat 6; do
words+=('')
repeat 2; do
for chars in $consonants $vowels $consonants; do
-$0-rand $#chars || return
words[-1]+=$chars[REPLY]
done
done
done
local pwd=${(j:-:)words}
# Replace either the first or the last character in one of
# the words with a random digit.
-$0-rand $#digits || return
local digit=$digits[REPLY]
-$0-rand $((2 * $#words)) || return
pwd[REPLY/2*7+2*(REPLY%2)-1]=$digit
# Convert one lower-case character to upper case.
while true; do
-$0-rand $#pwd || return
[[ $vowels$consonants == *$pwd[REPLY]* ]] && break
done
# NOTE: We aren't using ${(U)c} here because its results are
# locale-dependent. For example, when upper-casing 'i' in Turkish
# locale we would get 'İ', a.k.a. latin capital letter i with dot
# above. We could set LC_CTYPE=C locally but then we would run afoul
# of this zsh bug: https://www.zsh.org/mla/workers/2020/msg00588.html.
local c=$pwd[REPLY]
printf -v c '%o' $((#c - 32))
printf "%s\\$c%s\\n" "$pwd[1,REPLY-1]" "$pwd[REPLY+1,-1]" || return
done
} always {
unfunction -m -- "-${(b)0}-*"
} </dev/urandom

32
plugins/genpass/genpass-monkey Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env zsh
#
# Usage: genpass-monkey [NUM]
#
# Generate a password made of 26 alphanumeric characters
# with the security margin of at least 128 bits.
#
# Example password: nz5ej2kypkvcw0rn5cvhs6qxtm
#
# If given a numerical argument, generate that many passwords.
emulate -L zsh -o no_unset -o warn_create_global -o warn_nested_var
if [[ ARGC -gt 1 || ${1-1} != ${~:-<1-$((16#7FFFFFFF))>} ]]; then
print -ru2 -- "usage: $0 [NUM]"
return 1
fi
zmodload zsh/system || return
{
local -r chars=abcdefghjkmnpqrstvwxyz0123456789
local c
repeat ${1-1}; do
repeat 26; do
sysread -s1 c || return
# There is uniform because $#chars divides 256.
print -rn -- $chars[#c%$#chars+1]
done
print
done
} </dev/urandom

68
plugins/genpass/genpass-xkcd Executable file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env zsh
#
# Usage: genpass-xkcd [NUM]
#
# Generate a password made of words from /usr/share/dict/words
# with the security margin of at least 128 bits.
#
# Example password: 9-mien-flood-Patti-buxom-dozes-ickier-pay-ailed-Foster
#
# If given a numerical argument, generate that many passwords.
#
# The name of this utility is a reference to https://xkcd.com/936/.
emulate -L zsh -o no_unset -o warn_create_global -o warn_nested_var -o extended_glob
if [[ ARGC -gt 1 || ${1-1} != ${~:-<1-$((16#7FFFFFFF))>} ]]; then
print -ru2 -- "usage: $0 [NUM]"
return 1
fi
zmodload zsh/system zsh/mathfunc || return
local -r dict=/usr/share/dict/words
if [[ ! -e $dict ]]; then
print -ru2 -- "$0: file not found: $dict"
return 1
fi
# Read all dictionary words and leave only those made of 1-6 characters.
local -a words
words=(${(M)${(f)"$(<$dict)"}:#[a-zA-Z](#c1,6)}) || return
if (( $#words < 2 )); then
print -ru2 -- "$0: not enough suitable words in $dict"
return 1
fi
if (( $#words > 16#7FFFFFFF )); then
print -ru2 -- "$0: too many words in $dict"
return 1
fi
# Figure out how many words we need for 128 bits of security margin.
# Each word adds log2($#words) bits.
local -i n=$((ceil(128. / log2($#words))))
{
local c
repeat ${1-1}; do
print -rn -- $n
repeat $n; do
while true; do
# Generate a random number in [0, 2**31).
local -i rnd=0
repeat 4; do
sysread -s1 c || return
(( rnd = (~(1 << 23) & rnd) << 8 | #c ))
done
# Avoid bias towards words in the beginning of the list.
(( rnd < 16#7FFFFFFF / $#words * $#words )) || continue
print -rn -- -$words[rnd%$#words+1]
break
done
done
print
done
} </dev/urandom

View File

@ -1,106 +1 @@
autoload -U regexp-replace
zmodload zsh/mathfunc
genpass-apple() {
# Generates a 128-bit password of 6 pseudowords of 6 characters each
# EG, xudmec-4ambyj-tavric-mumpub-mydVop-bypjyp
# Can take a numerical argument for generating extra passwords
local -i i j num
[[ $1 =~ '^[0-9]+$' ]] && num=$1 || num=1
local consonants="$(LC_ALL=C tr -cd b-df-hj-np-tv-xz < /dev/urandom \
| head -c $((24*$num)))"
local vowels="$(LC_ALL=C tr -cd aeiouy < /dev/urandom | head -c $((12*$num)))"
local digits="$(LC_ALL=C tr -cd 0-9 < /dev/urandom | head -c $num)"
# The digit is placed on a pseudoword edge using $base36. IE, Dvccvc or cvccvD
local position="$(LC_ALL=C tr -cd 056bchinotuz < /dev/urandom | head -c $num)"
local -A base36=(0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 a 10 b 11 c 12 d 13 \
e 14 f 15 g 16 h 17 i 18 j 19 k 20 l 21 m 22 n 23 o 24 p 25 q 26 r 27 s 28 \
t 29 u 30 v 31 w 32 x 33 y 34 z 35)
for i in {1..$num}; do
local pseudo=""
for j in {1..12}; do
# Uniformly iterate through $consonants and $vowels for each $i and $j
# Creates cvccvccvccvccvccvccvccvccvccvccvccvc for each $num
pseudo="${pseudo}${consonants:$((24*$i+2*${j}-26)):1}"
pseudo="${pseudo}${vowels:$((12*$i+${j}-13)):1}"
pseudo="${pseudo}${consonants:$((24*$i+2*${j}-25)):1}"
done
local -i digit_pos=${base36[${position[$i]}]}
local -i char_pos=$digit_pos
# The digit and uppercase character must be in different locations
while [[ $digit_pos == $char_pos ]]; do
char_pos=$base36[$(LC_ALL=C tr -cd 0-9a-z < /dev/urandom | head -c 1)]
done
# Places the digit on a pseudoword edge
regexp-replace pseudo "^(.{$digit_pos}).(.*)$" \
'${match[1]}${digits[$i]}${match[2]}'
# Uppercase a random character (that is not a digit)
regexp-replace pseudo "^(.{$char_pos})(.)(.*)$" \
'${match[1]}${(U)match[2]}${match[3]}'
# Hyphenate each 6-character pseudoword
regexp-replace pseudo '^(.{6})(.{6})(.{6})(.{6})(.{6})(.{6})$' \
'${match[1]}-${match[2]}-${match[3]}-${match[4]}-${match[5]}-${match[6]}'
printf "${pseudo}\n"
done
}
genpass-monkey() {
# Generates a 128-bit base32 password as if monkeys banged the keyboard
# EG, nz5ej2kypkvcw0rn5cvhs6qxtm
# Can take a numerical argument for generating extra passwords
local -i i num
[[ $1 =~ '^[0-9]+$' ]] && num=$1 || num=1
local pass=$(LC_ALL=C tr -cd '0-9a-hjkmnp-tv-z' < /dev/urandom \
| head -c $((26*$num)))
for i in {1..$num}; do
printf "${pass:$((26*($i-1))):26}\n"
done
}
genpass-xkcd() {
# Generates a 128-bit XKCD-style passphrase
# e.g, 9-mien-flood-Patti-buxom-dozes-ickier-pay-ailed-Foster
# Can take a numerical argument for generating extra passwords
if (( ! $+commands[shuf] )); then
echo >&2 "$0: \`shuf\` command not found. Install coreutils (\`brew install coreutils\` on macOS)."
return 1
fi
if [[ ! -e /usr/share/dict/words ]]; then
echo >&2 "$0: no wordlist found in \`/usr/share/dict/words\`. Install one first."
return 1
fi
local -i i num
[[ $1 =~ '^[0-9]+$' ]] && num=$1 || num=1
# Get all alphabetic words of at most 6 characters in length
local dict=$(LC_ALL=C grep -E '^[a-zA-Z]{1,6}$' /usr/share/dict/words)
# Calculate the base-2 entropy of each word in $dict
# Entropy is e = L * log2(C), where L is the length of the password (here,
# in words) and C the size of the character set (here, words in $dict).
# Solve for e = 128 bits of entropy. Recall: log2(n) = log(n)/log(2).
local -i n=$((int(ceil(128*log(2)/log(${(w)#dict})))))
for i in {1..$num}; do
printf "$n-"
printf "$dict" | shuf -n "$n" | paste -sd '-' -
done
}
autoload -Uz genpass-apple genpass-monkey genpass-xkcd