From af51134dea544069d2f9f5419dadbad7200ba5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ar=C4=89i?= Date: Sat, 22 Jun 2024 11:59:13 +0200 Subject: [PATCH 1/2] enable pipefail --- contrib/pa-rekey | 7 +++++-- pa | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/contrib/pa-rekey b/contrib/pa-rekey index 6312685..b797a88 100755 --- a/contrib/pa-rekey +++ b/contrib/pa-rekey @@ -16,6 +16,9 @@ age=$(command -v age || command -v rage) || age_keygen=$(command -v age-keygen || command -v rage-keygen) || die "age-keygen not found, install per https://age-encryption.org" +# Ensure that pipelines return status of the last failed command. +set -o pipefail + # Restrict permissions of any new files to only the current user. umask 077 @@ -45,8 +48,8 @@ $age_keygen >>"$tmpdir/identities" 2>/dev/null $age_keygen -y "$tmpdir/identities" >>"$tmpdir/recipients" 2>/dev/null pa l | while read -r name; do - pa s "$name" | - $age -R "$tmpdir/recipients" -o "$tmpdir/passwords/$name.age" || + { pa s "$name" | + $age -R "$tmpdir/recipients" -o "$tmpdir/passwords/$name.age"; } || die "couldn't encrypt $name.age" done diff --git a/pa b/pa index a6df447..b9413aa 100755 --- a/pa +++ b/pa @@ -346,6 +346,10 @@ set +x # files to avoid TOCTOU vulnerabilities. set -C +# Ensure that pipelines return status of the +# last failed command. +set -o pipefail + # Restrict permissions of any new files to # only the current user. umask 077 From deafbd379fb5506f4dd309a1f070c11229342d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ar=C4=89i?= Date: Mon, 15 Sep 2025 21:37:39 +0200 Subject: [PATCH 2/2] overhaul multi-device recipients synchronization --- README | 6 +- contrib/pa-rekey | 86 ++++++++-------- pa | 254 +++++++++++++++++++++++++++++++++-------------- test | 68 ++++++++++++- 4 files changed, 293 insertions(+), 121 deletions(-) diff --git a/README b/README index d1a2fc3..4e402d2 100644 --- a/README +++ b/README @@ -5,17 +5,21 @@ features - encryption implemented using age[1] - automatic key generation + - yubikey, secure enclave and TPM support - automatic git tracking - multiple identity/recipient support - written in portable posix shell - simple to extend - - only ~200 lines of code + - only ~270 lines of code - pronounced "pah" - as in "papa" dependencies - age - age-keygen + - age-plugin-yubikey (optional) + - age-plugin-se (optional) + - age-plugin-tpm (optional) - git (optional) diff --git a/contrib/pa-rekey b/contrib/pa-rekey index b797a88..8fa8599 100755 --- a/contrib/pa-rekey +++ b/contrib/pa-rekey @@ -1,67 +1,65 @@ #!/bin/sh # # rotate keys and reencrypt passwords -# -# Reuse identities file: export PA_IDENTITIES=~/.local/share/pa/identities -# Reuse recipients file: export PA_RECIPIENTS=~/.local/share/pa/recipients -die() { - printf '%s: %s.\n' "$(basename "$0")" "$1" >&2 - exit 1 -} +set -e -age=$(command -v age || command -v rage) || - die "age not found, install per https://age-encryption.org" +# Complete any pending +# recipients synchronization. +pa l >/dev/null -age_keygen=$(command -v age-keygen || command -v rage-keygen) || - die "age-keygen not found, install per https://age-encryption.org" +: "${PA_DIR:=${XDG_DATA_HOME:-$HOME/.local/share}/pa}" -# Ensure that pipelines return status of the last failed command. -set -o pipefail +suffix=$(LC_ALL=C tr -dc A-Za-z0-9 /dev/null) -# Restrict permissions of any new files to only the current user. -umask 077 +[ "$suffix" ] -: "${PA_DIR:=${XDG_DATA_HOME:-$HOME/.local/share}/pa}" +tmp=$PA_DIR/rekey.$suffix + +mkdir "$tmp" -realstore=$(realpath "$PA_DIR/passwords") || - die "couldn't get path to password directory" +trap 'rm -f "$tmp/identities" "$tmp/recipients" "$tmp/passwords/.recipients" +rmdir "$tmp/passwords" "$tmp"' EXIT -tmpdir=$PA_DIR/tmp +cp -p "$PA_DIR/identities" "$tmp/identities" -mkdir "$tmpdir" || - die "couldn't create temporary directory" +# Generate recipients for current identities. +PA_DIR=$tmp PA_NOGIT='' pa l >/dev/null -trap 'rm -rf "$tmpdir"; exit' EXIT -trap 'rm -rf "$tmpdir"; trap - INT; kill -s INT 0' INT +# Filter out recipients corresponding to current identities. +{ grep -Fxvf "$tmp/passwords/.recipients" "$PA_DIR/recipients" || + :; } >"$tmp/recipients" -cp -Rp "$realstore" "$tmpdir/passwords" || - die "couldn't copy password directory" +rm -f "$tmp/identities" "$tmp/passwords/.recipients" -# Remove git repository for forward secrecy. -rm -rf "$tmpdir/passwords/.git" +# Generate a brand new key pair. +PA_DIR=$tmp PA_NOGIT='' pa l >/dev/null -[ "$PA_IDENTITIES" ] && cp "$PA_IDENTITIES" "$tmpdir/identities" -[ "$PA_RECIPIENTS" ] && cp "$PA_RECIPIENTS" "$tmpdir/recipients" +printf 'add a new generated recipient? [y/N]: ' >&2 -$age_keygen >>"$tmpdir/identities" 2>/dev/null -$age_keygen -y "$tmpdir/identities" >>"$tmpdir/recipients" 2>/dev/null +[ -t 0 ] && trap 'stty echo icanon; trap - INT; kill -s INT 0' INT -pa l | while read -r name; do - { pa s "$name" | - $age -R "$tmpdir/recipients" -o "$tmpdir/passwords/$name.age"; } || - die "couldn't encrypt $name.age" -done +[ -t 0 ] && stty -echo -icanon -trap - INT EXIT +answer=$(dd ibs=1 count=1 2>/dev/null) -rm -rf "$realstore" || - die "couldn't remove password directory" +[ -t 0 ] && stty echo icanon -mv "$tmpdir/passwords" "$realstore" -mv "$tmpdir/identities" "$(realpath "$PA_DIR/identities")" -mv "$tmpdir/recipients" "$(realpath "$PA_DIR/recipients")" -rmdir "$tmpdir" +printf '%s\n' "${answer:-N}" >&2 -# Recreate git repository if needed. +case $answer in [yY]) ;; *) exit 1 ;; esac + +# Ensure files can be decrypted with both +# identities if store is in a partial state. +cat "$tmp/identities" >>"$PA_DIR/identities" + +# Recipients are now ready to use. +mv "$tmp/recipients" "$PA_DIR/recipients" + +# Reencrypt all passwords +# for new recipients. pa l >/dev/null + +# Old identity is now safe to remove. +mv "$tmp/identities" "$PA_DIR/identities" diff --git a/pa b/pa index b9413aa..8cff2f4 100755 --- a/pa +++ b/pa @@ -3,8 +3,10 @@ # pa - a simple password manager pw_add() { - if yn "generate a password?"; then - pass=$(rand_chars "${PA_LENGTH:-50}" "${PA_PATTERN:-A-Za-z0-9-_}") || + if ask "generate a password? [y/N]" N; then + pass=$(rand_chars "${PA_LENGTH:-50}" "${PA_PATTERN:-A-Za-z0-9-_}") + + [ "$pass" ] || die "couldn't generate a password" else # 'sread()' is a simple wrapper function around 'read' @@ -40,57 +42,43 @@ pw_add() { printf '%s\n' "saved '$name' to the store." - $git_enabled && git_add_and_commit "./$name.age" "add '$name'" + if $git_enabled; then git_add_and_commit "./$name.age" "add '$name'"; fi } pw_edit() { - # Prefer /dev/shm because it's an in-memory - # space that we can use to store data without - # having bits laying around in sectors. - tmpdir=/dev/shm - # Fall back to $TMPDIR or /tmp - /dev/shm is Linux-only - # and shared memory space on other operating systems - # have non-standard methods of setup/access. - [ -w /dev/shm ] || tmpdir=${TMPDIR:-/tmp} - - # Reimplement mktemp here, because - # mktemp isn't defined in POSIX. - new=true tmpfile=$tmpdir/pa.$(rand_chars 10 'A-Za-z0-9') || - die "couldn't generate random characters" - - (: >"$tmpfile") 2>/dev/null || + new=true edittmp=$(mkstemp) || die "couldn't create a shared memory filename" - trap 'rm -f "$tmpfile"' EXIT + trap 'rm -f "$edittmp"' EXIT [ -f "$name.age" ] && new=false && - { $age --decrypt -i "$identities_file" -o "$tmpfile" "./$name.age" || + { $age --decrypt -i "$identities_file" -o "$edittmp" "./$name.age" || die "couldn't decrypt $name.age"; } - ${EDITOR:-vi} "$tmpfile" || - die "EDITOR exited non-zero" + ${EDITOR:-vi} "$edittmp" || + die "editor exited unsuccessfully" - [ -s "$tmpfile" ] || return + [ -s "$edittmp" ] || return mkdir -p "$(dirname -- "$name")" || die "couldn't create category '$(dirname -- "$name")'" - $age --encrypt -R "$recipients_file" -o "./$name.age" "$tmpfile" || + $age --encrypt -R "$recipients_file" -o "./$name.age" "$edittmp" || die "couldn't encrypt $name.age" if $new; then printf '%s\n' "saved '$name' to the store."; fi - $git_enabled && git_add_and_commit "./$name.age" "edit '$name'" + if $git_enabled; then git_add_and_commit "./$name.age" "edit '$name'"; fi } pw_del() { - yn "delete password '$name'?" || return + ask "delete password '$name'? [y/N]" N || return rm -f "./$name.age" rmdir -p "$(dirname -- "$name")" 2>/dev/null || : - $git_enabled && git_add_and_commit "./$name.age" "delete '$name'" + if $git_enabled; then git_add_and_commit "./$name.age" "delete '$name'"; fi } pw_show() { @@ -114,12 +102,108 @@ pw_move() { fi } +change_recipients() { + cmp -s "$recipients_file" .recipients 2>/dev/null && return + + [ -f "$PA_DIR/lock" ] && + die "locked to change recipients" + + new_local=false revoke=false new_count=0 old_count='' passtmp=$(mkstemp) || + die "couldn't create a shared memory filename" + + # Compare recipients files to transfer new locally added recipients. + grep -Fxvf .recipients "$recipients_file" >|"$passtmp" && new_local=true + + trap 'rm -f "$passtmp" "$PA_DIR/lock"' EXIT + + # Compare recipients files to transfer new remotely added recipients. + if new_remote=false && grep -Fxvf "$recipients_file" .recipients >&2 && + new_remote=true && ! ask "do you trust these recipients? [y/N]" N; then + old_count=$(wc -l <.recipients) + cp "$recipients_file" .recipients + elif $new_local; then + cat "$passtmp" >>.recipients + elif $new_remote; then + new_count=$(wc -l <.recipients) + # No new recipients means only their order + # is different, so no need to re-encrypt. + else + cp .recipients "$recipients_file" + + return + fi + + total=$(pw_list | nl -w 1 | tee "$PA_DIR/lock" | tail -n 1 | cut -f 1) + + # Try to re-encrypt all passwords + # for new recipients. + while read -r i passname; do + count=$(grep -c '^-> ' "./$passname.age") + + # Compare the number of recipient stanzas in the encrypted password file + # with the number of remote recipients to not duplicate re-encryption of + # passwords as it might have already been done on another device. + [ "$new_count" -eq "$count" ] && continue + + # Check if file was encrypted to removed recipients. + [ "$old_count" ] && [ "${old_count:-0}" -le "$count" ] && revoke=true + + # Skip re-encryption of file that isn't encrypted to removed recipients. + [ "$old_count" ] && ! $new_local && ! $revoke && continue + + [ -t 2 ] && printf 're-encrypting password %d of %d\r' "$i" "$total" >&2 + + $age --decrypt -i "$identities_file" "./$passname.age" 2>/dev/null | + $age -R .recipients -o "$passtmp" && mv "$passtmp" "./$passname.age" + done <"$PA_DIR/lock" + + # To prepare for next output, use spaces to clean up the progress + # line, which has 27 non-digits and 2 numbers (current and total). + [ -t 2 ] && [ "$total" ] && printf '%*s\r' "$((${#total} * 2 + 27))" "" >&2 + + cp .recipients "$recipients_file" + + [ "$old_count" ] && [ "${total:-0}" -gt 0 ] && if $revoke; then + printf '\n%s\n' "recipients were revoked, but holders of +corresponding identities are still able +to decrypt old copies of password files." >&2 + + [ -d .git ] && find .git ! -name config -type f -exec rm -f {} + && + printf '%s\n' "repo is recreated to avoid leaks." >&2 + + printf '%s\n\n' "YOU SHOULD CHANGE YOUR PASSWORDS!" >&2 + else + printf '\n%s\n\n' "the remote you sync with tried to +add untrusted recipients, to stop +it you should set another remote!" >&2 + fi + + # Only try to commit if there are any untracked files or unstaged changes. + if $git_enabled && git init -q && git status --porcelain | grep -q .; then + git_add_and_commit . "change recipients" + fi +} + git_add_and_commit() { - git add "$1" || - die "couldn't git add $1" + git add "$1" && git commit -qm "$2" +} + +mkstemp() { + # Prefer /dev/shm because it's an in-memory + # space that we can use to store data without + # having bits laying around in sectors. + # Fall back to /tmp - /dev/shm is Linux-only & /tmp + # and shared memory space on other operating systems + # have non-standard methods of setup/access. + if [ -w /dev/shm ]; then tmpdir=/dev/shm; else tmpdir=/tmp; fi + + suffix=$(rand_chars 10 'A-Za-z0-9') + + [ "$suffix" ] || return - git commit -qm "$2" || - die "couldn't git commit $2" + tmpfile=$tmpdir/pa.$suffix + + (: >"$tmpfile") 2>/dev/null && printf %s "$tmpfile" } rand_chars() { @@ -137,8 +221,8 @@ rand_chars() { LC_ALL=C tr -dc "$2" /dev/null } -yn() { - printf '%s [y/N]: ' "$1" +ask() { + printf '%s: ' "$1" >&2 # Enable raw input to allow for a single byte to be read from # stdin without needing to wait for the user to press Return. @@ -152,7 +236,7 @@ yn() { # have found it. [ -t 0 ] && stty echo icanon - printf '%s\n' "$answer" + printf '%s\n' "${answer:-$2}" >&2 # Handle the answer here directly, enabling this function's # return status to be used in place of checking for '[yY]' @@ -161,7 +245,7 @@ yn() { } sread() { - printf '%s: ' "$2" + printf '%s: ' "$2" >&2 # Disable terminal printing while the user inputs their # password. POSIX 'read' has no '-s' flag which would @@ -170,7 +254,7 @@ sread() { read -r "$1" [ -t 0 ] && stty echo - printf '\n' + printf '\n' >&2 } glob() { @@ -217,9 +301,6 @@ main() { age=$(command -v age || command -v rage) || die "age not found, install per https://age-encryption.org" - age_keygen=$(command -v age-keygen || command -v rage-keygen) || - die "age-keygen not found, install per https://age-encryption.org" - : "${PA_DIR:=${XDG_DATA_HOME:-$HOME/.local/share}/pa}" glob "$PA_DIR" '/*' || @@ -234,37 +315,83 @@ main() { cd "$PA_DIR/passwords" || die "couldn't change to password directory" - # Ensure that globbing is disabled - # to avoid insecurities with word-splitting. - set -f + # Ensure that we leave the terminal in a usable state on Ctrl+C. + [ -t 0 ] && trap 'stty echo icanon; trap - INT; kill -s INT 0' INT + + [ ! -s "$identities_file" ] || [ ! -s "$recipients_file" ] && r='' && { + y= && command -v age-plugin-yubikey >/dev/null && y='[y]ubikey/' + s= && command -v age-plugin-se >/dev/null && s='[s]e/' + t= && command -v age-plugin-tpm >/dev/null && t='[t]pm/' + { ! command -v age-keygen && command -v rage-keygen; } >/dev/null && r=r + + [ -s "$identities_file" ] || if [ "$y" ] || [ "$s" ] || [ "$t" ] && + printf "choose identity generator (" >&2 && + ask "$y$s$t${r}age-keygen)" && [ "$y" ]; then + age-plugin-yubikey -g --name "pa identity" --pin-policy never + elif [ "$s" ] && glob "$answer" [sS]; then + age-plugin-se keygen 2>/dev/null + elif [ "$t" ] && glob "$answer" [tT]; then + age-plugin-tpm --generate + else + "${r}age-keygen" 2>/dev/null + fi >|"$identities_file" || + die "couldn't generate an identity" + + grep -q ^AGE-PLUGIN-YUBIKEY- "$identities_file" && + # Get recipients only for keys in identities file. + age-plugin-yubikey -i 2>/dev/null | awk 'FNR==NR&&$0&&!/^#/{a[$0] + next}$0 in a{print p}{p=$NF}' "$identities_file" - + + grep -q ^AGE-PLUGIN-SE- "$identities_file" && + grep ^AGE-PLUGIN-SE- "$identities_file" | age-plugin-se recipients + + grep -q ^AGE-PLUGIN-TPM- "$identities_file" && + grep ^AGE-PLUGIN-TPM- "$identities_file" | age-plugin-tpm -y + + grep -q ^AGE-SECRET-KEY- "$identities_file" && + grep ^AGE-SECRET-KEY- "$identities_file" | "${r}age-keygen" -y + } >>"$recipients_file" + + [ -s "$recipients_file" ] || + die "couldn't generate recipients" + + [ -f .recipients ] || cp "$recipients_file" .recipients git_enabled=false - [ -z "${PA_NOGIT+x}" ] && command -v git >/dev/null 2>&1 && git_enabled=true + [ -z "${PA_NOGIT+x}" ] && command -v git >/dev/null && git_enabled=true $git_enabled && [ ! -d .git ] && { git init -q + git config pull.rebase false + # Put something in user config if it's not set globally, # because git doesn't allow to commit without it. - git config user.name >/dev/null || git config user.name pa - git config user.email >/dev/null || git config user.email "" + git config user.email >/dev/null || + git config user.email "${EMAIL:-$(id -un)@$(uname -n)}" # Configure diff driver for age encrypted files that treats them as # binary and decrypts them when a human-readable diff is requested. git config diff.age.binary true git config diff.age.textconv "$age --decrypt -i '$identities_file'" - # Assign this diff driver to all passwords. - printf '%s\n' '*.age diff=age' >.gitattributes + # Always prefer our version of + # files after merge conflicts. + git config merge.ours.driver true + + printf '%s\n' "*.age diff=age merge=ours +.gitattributes merge=union +.recipients merge=union" >|.gitattributes - git_add_and_commit . "initial commit" + git_add_and_commit . "initial commit" || + die "couldn't create initial commit" } command=$1 shift glob "$command" 'g*' && { - git "$@" + git "$@" && change_recipients exit $? } @@ -302,30 +429,7 @@ main() { glob "$command" 'l*' && [ "$name" ] && [ ! -d "$name" ] && die "category '$name' doesn't exist" - if command -v age-plugin-yubikey >/dev/null 2>&1; then - [ ! -f "$identities_file" ] && [ ! -f "$recipients_file" ] && { - yn "generate yubikey identity?" && { - age-plugin-yubikey \ - --generate \ - --name "pa identity" \ - --pin-policy never \ - --touch-policy always >"$identities_file" || - die 'failed to generate YubiKey identity file' - - age-plugin-yubikey -l >"$recipients_file" || - die 'failed to generate YubiKey recipients file' - } - } - fi - - [ -f "$identities_file" ] || - $age_keygen -o "$identities_file" 2>/dev/null - - [ -f "$recipients_file" ] || - $age_keygen -y -o "$recipients_file" "$identities_file" 2>/dev/null - - # Ensure that we leave the terminal in a usable state on Ctrl+C. - [ -t 0 ] && trap 'stty echo icanon; trap - INT; kill -s INT 0' INT + name='' change_recipients case $command in a*) pw_add ;; @@ -346,6 +450,10 @@ set +x # files to avoid TOCTOU vulnerabilities. set -C +# Ensure that globbing is disabled +# to avoid insecurities with word-splitting. +set -f + # Ensure that pipelines return status of the # last failed command. set -o pipefail diff --git a/test b/test index 7b9ea11..cf57907 100755 --- a/test +++ b/test @@ -22,7 +22,7 @@ rm -rf /tmp/pa-test fail "pa should print a welcome message" # generate pa dirs/identityfile/recipientfile -./pa list +printf n | ./pa list >/dev/null 2>&1 # pa auto-generated files are correct test -s "$PA_DIR/identities" || @@ -36,8 +36,17 @@ test -d "$PA_DIR/passwords/.git" || # TODO: ensure git author/email are set correctly, etc # pa add -printf 'y' | ./pa add test 2>&1 >/dev/null || - fail "pa add should be capable of adding a test password" +printf 'nsecret\nsecret\n' | ./pa add test >/dev/null 2>&1 || + fail "pa add should be capable of adding a password" + +already_exists_output=$(./pa add test 2>&1) && + fail "pa add shouldn't succeed when password exists" + +test "$already_exists_output" = "pa: password 'test' already exists." || + fail "pa add should say when password already exists" + +printf y | ./pa add random >/dev/null 2>&1 || + fail "pa add should be capable of generating a password" test "$(printf y | ./pa add nested/password 2>&1)" = "\ generate a password? [y/N]: y @@ -47,11 +56,19 @@ saved 'nested/password' to the store." || test -s "$PA_DIR/passwords/nested/password.age" || fail "pa add should create an encrypted password file" +# pa show +test "$(./pa show test)" = "secret" || + fail "pa show should reveal test password" + +test "$(./pa show random | wc -c)" = 51 || + fail "pa show with generated password should output 50 chars and a newline" + # pa list ./pa list | grep -q test || fail "pa list should list the test password" test "$(./pa list)" = "nested/password +random test" || fail "pa list output should match example" @@ -69,6 +86,51 @@ test -s "$PA_DIR/passwords/another/nested password.age" || git -C "$PA_DIR/passwords" log | grep -q "add 'nested/password'" || fail "git log should have line: add 'nested/password'" +# outgoing recipients sync +age-keygen >>"$PA_DIR/identities" 2>/dev/null && rm "$PA_DIR/recipients" + +./pa list >/dev/null + +test "$(grep -c ^age1 "$PA_DIR/recipients")" = 2 || + fail "a recipients file should contain 2 recipients" + +ex -sc '1,$-1d|x' "$PA_DIR/identities" + +test "$(./pa show test)" = "secret" || + fail "new recipient should be able to show existing passwords" + +cmp -s "$PA_DIR/recipients" "$PA_DIR/passwords/.recipients" || + fail "both recipients files should be equal" + +git -C "$PA_DIR/passwords" log | grep -q "change recipients" || + fail "git log should have line: change recipients" + +# incoming recipients sync +ex -sc '1,$-1d|x' "$PA_DIR/recipients" + +printf y | ./pa list 2>&1 | grep "do you trust these recipients?" >/dev/null || + fail "pa should ask to add incoming recipients" + +cmp -s "$PA_DIR/recipients" "$PA_DIR/passwords/.recipients" || + fail "both recipients files should be equal" + +test "$(grep -c ^age1 "$PA_DIR/recipients")" = 2 || + fail "a recipients file should contain 2 recipients" + +ex -sc '1,$-1d|x' "$PA_DIR/recipients" + +printf n | ./pa list 2>&1 | grep "do you trust these recipients?" >/dev/null || + fail "pa should ask to add incoming recipients" + +cmp -s "$PA_DIR/recipients" "$PA_DIR/passwords/.recipients" || + fail "both recipients files should be equal" + +test "$(grep -c ^age1 "$PA_DIR/recipients")" = 1 || + fail "a recipients file should contain 1 recipient" + +test "$(git -C "$PA_DIR/passwords" log --format=%s)" = "change recipients" || + fail "git repository should have a single commit titled change recipients" + # print info & exit w/ correct status printf "\ntotal failures: %d\n" "$failures" test "$failures" -eq 0