all repos — pa @ master

my personal password manager

pa

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
#!/bin/sh
#
# pa - simple age-based password manager

pw_add() {
    name=$1

    if yn "Generate a password?"; then
        # Generate a password by reading '/dev/urandom' with the
        # 'tr' command to translate the random bytes into a
        # configurable character set.
        #
        # The 'dd' command is then used to read only the desired
        # password length.
        #
        # Regarding usage of '/dev/urandom' instead of '/dev/random'.
        # See: https://www.2uo.de/myths-about-urandom
        pass=$(LC_ALL=C tr -dc "${PA_PATTERN:-_A-Z-a-z-0-9}" </dev/urandom |
            dd ibs=1 obs=1 count="${PA_LENGTH:-50}" 2>/dev/null)

    else
        # 'sread()' is a simple wrapper function around 'read'
        # to prevent user input from being printed to the terminal.
        sread pass "Enter password"
        sread pass2 "Enter password (again)"

        # Disable this check as we dynamically populate the two
        # passwords using the 'sread()' function.
        # shellcheck disable=2154
        [ "$pass" = "$pass2" ] || die "Passwords do not match"
    fi

    [ "$pass" ] || die "Failed to generate a password"

    # Mimic the use of an array for storing arguments by... using
    # the function's argument list. This is very apt isn't it?
    set -- -c

    # Use 'age' to store the password in an encrypted file.
    # A heredoc is used here instead of a 'printf' to avoid
    # leaking the password through the '/proc' filesystem.
    #
    # Heredocs are sometimes implemented via temporary files,
    # however this is typically done using 'mkstemp()' which
    # is more secure than a leak in '/proc'.
    age -r "$pubkey" -o "$name.age" <<-EOF &&
		$pass
	EOF
        printf '%s\n' "Saved '$name' to the store."
}

pw_edit() {
    name=$1

    [ -f "$name.age" ] || die "Failed to access $name"

    # we use /dev/shm because it's an in-memory
    # space that we can use to store private data,
    # and securely wipe it without worrying about
    # residual badness
    [ -d /dev/shm ] || die "Failed to access /dev/shm"

    # get base dirname in case we're dealing with
    # a nested item (foo/bar)
    tmpfile="/dev/shm/pa/$name.txt"
    tmpdir="$(dirname "$tmpfile")"
    mkdir -p "$tmpdir"
    trap 'rm -rf /dev/shm/pa' EXIT

    age -i ~/.age/key.txt --decrypt "$name.age" 2>/dev/null >"$tmpfile" ||
        die "Could not decrypt $name.age"

    "${EDITOR:-vi}" "$tmpfile"

    [ -f "$tmpfile" ] || die "New password not saved"

    rm "$name.age"
    age -r "$pubkey" -o "$name.age" "$tmpfile"
}

pw_del() {
    yn "Delete pass file '$1'?" && {
        rm -f "$1.age"

        # Remove empty parent directories of a password
        # entry. It's fine if this fails as it means that
        # another entry also lives in the same directory.
        rmdir -p "${1%/*}" 2>/dev/null || :
    }
}

pw_show() {
    age -i ~/.age/key.txt --decrypt "$1.age" 2>/dev/null ||
        die "Could not decrypt $1.age"
}

pw_list() {
    find . -type f -name \*.age | sed 's/..//;s/\.age$//'
}

pw_gen() {
    if yn "$HOME/.age/key.txt not detected, generate a new one?"; then
        mkdir -p ~/.age
        age-keygen -o ~/.age/key.txt
    fi
}

yn() {
    printf '%s [y/n]: ' "$1"

    # Enable raw input to allow for a single byte to be read from
    # stdin without needing to wait for the user to press Return.
    stty -icanon

    # Read a single byte from stdin using 'dd'. POSIX 'read' has
    # no support for single/'N' byte based input from the user.
    answer=$(dd ibs=1 count=1 2>/dev/null)

    # Disable raw input, leaving the terminal how we *should*
    # have found it.
    stty icanon

    printf '\n'

    # Handle the answer here directly, enabling this function's
    # return status to be used in place of checking for '[yY]'
    # throughout this program.
    glob "$answer" '[yY]'
}

sread() {
    printf '%s: ' "$2"

    # Disable terminal printing while the user inputs their
    # password. POSIX 'read' has no '-s' flag which would
    # effectively do the same thing.
    stty -echo
    read -r "$1"
    stty echo

    printf '\n'
}

glob() {
    # This is a simple wrapper around a case statement to allow
    # for simple string comparisons against globs.
    #
    # Example: if glob "Hello World" '* World'; then
    #
    # Disable this warning as it is the intended behavior.
    # shellcheck disable=2254
    case $1 in $2) return 0 ;; esac
    return 1
}

die() {
    printf 'error: %s.\n' "$1" >&2
    exit 1
}

usage() {
    printf %s "\
pa 0.1.0 - age-based password manager
=> [a]dd  [name] - Create a new password, randomly generated
=> [d]el  [name] - Delete a password entry.
=> [e]dit [name] - Edit a password entry with $EDITOR.
=> [l]ist        - List all entries.
=> [s]how [name] - Show password for an entry.
Password length:   export PA_LENGTH=50
Password pattern:  export PA_PATTERN=_A-Z-a-z-0-9
Store location:    export PA_DIR=~/.local/share/pa
"
    exit 0
}

main() {
    : "${PA_DIR:=${XDG_DATA_HOME:=$HOME/.local/share}/pa}"

    command -v age >/dev/null 2>&1 ||
        die "age not found, install per https://github.com/FiloSottile/age"

    command -v age-keygen >/dev/null 2>&1 ||
        die "age-keygen not found, install per https://github.com/FiloSottile/age"

    mkdir -p "$PA_DIR" ||
        die "Couldn't create password directory"

    cd "$PA_DIR" ||
        die "Can't access password directory"

    glob "$1" '[acdes]*' && [ -z "$2" ] &&
        die "Missing [name] argument"

    glob "$1" '[cds]*' && [ ! -f "$2.age" ] &&
        die "Pass file '$2' doesn't exist"

    glob "$1" 'a*' && [ -f "$2.age" ] &&
        die "Pass file '$2' already exists"

    glob "$2" '*/*' && glob "$2" '*../*' &&
        die "Category went out of bounds"

    glob "$2" '/*' &&
        die "Category can't start with '/'"

    glob "$2" '*/*' && { mkdir -p "${2%/*}" ||
        die "Couldn't create category '${2%/*}'"; }

    # Restrict permissions of any new files to
    # only the current user.
    umask 077

    [ -f ~/.age/key.txt ] || pw_gen
    pubkey=$(sed -n 's/.*\(age\)/\1/p' ~/.age/key.txt)

    # Ensure that we leave the terminal in a usable
    # state on exit or Ctrl+C.
    [ -t 1 ] && trap 'stty echo icanon' INT EXIT

    case $1 in
    a*) pw_add "$2" ;;
    d*) pw_del "$2" ;;
    e*) pw_edit "$2" ;;
    s*) pw_show "$2" ;;
    l*) pw_list ;;
    *) usage ;;
    esac
}

# Ensure that debug mode is never enabled to
# prevent the password from leaking.
set +x

# Ensure that globbing is globally disabled
# to avoid insecurities with word-splitting.
set -f

[ "$1" ] || usage && main "$@"