pashage.sh (42189B)
1 #!/bin/sh 2 # pashage - age-backed POSIX password manager 3 # Copyright (C) 2024-2025 Natasha Kerensikova 4 # 5 # This program is free software; you can redistribute it and/or 6 # modify it under the terms of the GNU General Public License 7 # as published by the Free Software Foundation; either version 2 8 # of the License, or (at your option) any later version. 9 # 10 # This program is distributed in the hope that it will be useful, 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 # GNU General Public License for more details. 14 # 15 # You should have received a copy of the GNU General Public License 16 # along with this program; if not, write to the Free Software 17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 19 NL="$(printf '\nend')" 20 NL="${NL%end}" 21 22 ############################# 23 # INTERNAL HELPER FUNCTIONS # 24 ############################# 25 26 # Check a path and abort if it looks suspicious 27 # $1: path to check 28 check_sneaky_path() { 29 if [ "$1" = ".." ] \ 30 || [ "$1" = "../${1#../}" ] \ 31 || [ "$1" = "${1%/..}/.." ] \ 32 || ! [ "$1" = "${1##*/../}" ] \ 33 && [ -n "$1" ] 34 then 35 die "Encountered path considered sneaky: \"$1\"" 36 fi 37 } 38 39 # Check paths and abort if any looks suspicious 40 check_sneaky_paths() { 41 for ARG in "$@"; do 42 check_sneaky_path "${ARG}" 43 done 44 unset ARG 45 } 46 47 # Run the arguments as a command an die on failure 48 checked() { 49 if "$@"; then 50 : 51 else 52 CODE="$?" 53 printf '%s\n' "Fatal(${CODE}): $*" >&2 54 exit "${CODE}" 55 fi 56 } 57 58 # Output an error message and quit immediately 59 die() { 60 printf '%s\n' "$*" >&2 61 exit 1 62 } 63 64 # Always-successful grep filter 65 # ... grep arguments 66 grep_filter() { 67 grep "$@" || true 68 } 69 70 # Generate random characters 71 # $1: number of characters 72 # $2: allowed character set 73 random_chars() { 74 ( export LC_ALL=C; \ 75 tr -dc -- "$2" </dev/urandom | dd ibs=1 obs=1 count="$1" \ 76 2>/dev/null || true ) 77 } 78 79 # Find the deepest recipient file above the given path 80 set_LOCAL_RECIPIENT_FILE() { 81 LOCAL_RECIPIENT_FILE="/$1" 82 83 while [ -n "${LOCAL_RECIPIENT_FILE}" ] \ 84 && ! [ -f "${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients" ] 85 do 86 LOCAL_RECIPIENT_FILE="${LOCAL_RECIPIENT_FILE%/*}" 87 done 88 89 if ! [ -f "${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients" ]; then 90 LOCAL_RECIPIENT_FILE= 91 LOCAL_RECIPIENTS= 92 return 0 93 fi 94 95 LOCAL_RECIPIENT_FILE="${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients" 96 LOCAL_RECIPIENTS="$(cat "${LOCAL_RECIPIENT_FILE}")" 97 } 98 99 # Count how many characters are in the first argument 100 # $1: string to measure 101 strlen(){ 102 RESULT=0 103 STR="$1" 104 while [ -n "${STR}" ]; do 105 RESULT=$((RESULT + 1)) 106 STR="${STR#?}" 107 done 108 printf '%s\n' "${RESULT}" 109 unset RESULT 110 unset STR 111 } 112 113 # Ask for confirmation 114 # $1: Prompt 115 yesno() { 116 printf '%s [y/n]' "$1" 117 118 if type stty >/dev/null 2>&1 && stty >/dev/null 2>&1; then 119 120 # Enable raw input to allow for a single byte to be read from 121 # stdin without needing to wait for the user to press Return. 122 stty -icanon 123 124 ANSWER='' 125 126 while [ "${ANSWER}" = "${ANSWER#[NnYy]}" ]; do 127 # Read a single byte from stdin using 'dd'. 128 # POSIX 'read' has no support for single/'N' byte 129 # based input from the user. 130 ANSWER=$(dd ibs=1 count=1 2>/dev/null) 131 done 132 133 # Disable raw input, leaving the terminal how we *should* 134 # have found it. 135 stty icanon 136 137 printf '\n' 138 else 139 read -r ANSWER 140 ANSWER="${ANSWER%"${ANSWER#?}"}" 141 fi 142 143 if [ "${ANSWER}" = Y ]; then 144 ANSWER=y 145 fi 146 } 147 148 149 ################## 150 # SCM MANAGEMENT # 151 ################## 152 153 # Add a file or directory to pending changes 154 # $1: path 155 scm_add() { 156 [ -d "${PREFIX}/.git" ] || return 0 157 git -C "${PREFIX}" add -- "$1" 158 } 159 160 # Start a sequence of changes, asserting nothing is pending 161 scm_begin() { 162 [ -d "${PREFIX}/.git" ] || return 0 163 if [ -n "$(git -C "${PREFIX}" status --porcelain || true)" ]; then 164 die "There are already pending changes." 165 fi 166 } 167 168 # Commit pending changes 169 # $1: commit message 170 scm_commit() { 171 [ -d "${PREFIX}/.git" ] || return 0 172 if [ -n "$(git -C "${PREFIX}" status --porcelain || true)" ]; then 173 git -C "${PREFIX}" commit -m "$1" >/dev/null 174 fi 175 } 176 177 # Copy a file or directory in the filesystem and put it in pending changes 178 # $1: source 179 # $2: destination 180 scm_cp() { 181 cp -rf -- "${PREFIX}/$1" "${PREFIX}/$2" 182 scm_add "$2" 183 } 184 185 # Add deletion of a file or directory to pending changes 186 # $1: path 187 scm_del() { 188 [ -d "${PREFIX}/.git" ] || return 0 189 git -C "${PREFIX}" rm -qr -- "$1" 190 } 191 192 # Move a file or directory in the filesystem and put it in pending changes 193 # $1: source 194 # $2: destination 195 scm_mv() { 196 if [ -d "${PREFIX}/.git" ]; then 197 git -C "${PREFIX}" mv -f -- "$1" "$2" 198 else 199 mv -f -- "${PREFIX}/$1" "${PREFIX}/$2" 200 fi 201 } 202 203 # Delete a file or directory from filesystem and put it in pending changes 204 scm_rm() { 205 rm -rf -- "${PREFIX:?}/$1" 206 scm_del "$1" 207 } 208 209 210 ########### 211 # ACTIONS # 212 ########### 213 214 # Copy or move (depending on ${ACTION}) a secret file or directory 215 # $1: source name 216 # $2: destination name 217 # ACTION: Copy or Move 218 # DECISION: whether to re-encrypt or copy/move 219 # OVERWRITE: whether to overwrite without confirmation 220 # SCM_ACTION: scm_cp or scm_mv 221 do_copy_move() { 222 if [ "$1" = "${1%/}/" ]; then 223 if ! [ -d "${PREFIX}/$1" ]; then 224 die "Error: $1 is not in the password store." 225 fi 226 SRC="$1" 227 elif [ -e "${PREFIX}/$1.age" ] && ! [ -d "${PREFIX}/$1.age" ]; then 228 SRC="$1.age" 229 elif [ -n "$1" ] && [ -d "${PREFIX}/$1" ]; then 230 SRC="$1/" 231 elif [ -e "${PREFIX}/$1" ]; then 232 SRC="$1" 233 else 234 die "Error: $1 is not in the password store." 235 fi 236 237 if [ -z "${SRC}" ] || [ "${SRC}" = "${SRC%/}/" ]; then 238 LOCAL_ACTION=do_copy_move_dir 239 if [ -d "${PREFIX}/$2" ]; then 240 DEST="${2%/}${2:+/}$(basename "${SRC%/}")/" 241 if [ -e "${PREFIX}/${DEST}" ] \ 242 && [ "${ACTION}" = Move ] 243 then 244 die "Error: $2 already contains" \ 245 "$(basename "${SRC%/}")" 246 fi 247 else 248 DEST="${2%/}${2:+/}" 249 if [ -e "${PREFIX}/${DEST%/}" ]; then 250 die "Error: ${DEST%/} is not a directory" 251 fi 252 fi 253 254 elif [ "$2" = "${2%/}/" ] || [ -d "${PREFIX}/$2" ]; then 255 [ -d "${PREFIX}/$2" ] || mkdir -p -- "${PREFIX}/$2" 256 [ -d "${PREFIX}/$2" ] || die "Error: $2 is not a directory" 257 258 DEST="${2%/}/$(basename "${SRC}")" 259 if [ -d "${PREFIX}/${DEST}" ]; then 260 die "Error: $2 already contains $(basename "${SRC}")/" 261 fi 262 LOCAL_ACTION=do_copy_move_file 263 264 else 265 if [ "${SRC}" = "${SRC%.age}.age" ] \ 266 && ! [ "$2" = "${2%.age}.age" ] 267 then 268 DEST="$2.age" 269 else 270 DEST="$2" 271 fi 272 273 mkdir -p -- "$(dirname "${PREFIX}/${DEST}")" 274 LOCAL_ACTION=do_copy_move_file 275 fi 276 277 scm_begin 278 SCM_COMMIT_MSG="${ACTION} ${SRC} to ${DEST}" 279 280 "${LOCAL_ACTION}" "${SRC}" "${DEST}" 281 282 scm_commit "${SCM_COMMIT_MSG}" 283 284 unset LOCAL_ACTION 285 unset SRC 286 unset DEST 287 unset SCM_COMMIT_MSG 288 } 289 290 # Copy or move a secret directory (depending on ${ACTION}) 291 # $1: source directory name (with a trailing slash) 292 # $2: destination directory name (with a trailing slash) 293 # DECISION: whether to re-encrypt or copy/move 294 # SCM_ACTION: scm_cp or scm_mv 295 do_copy_move_dir() { 296 [ "$1" = "${1%/}/" ] || [ -z "$1" ] || die 'Internal error' 297 [ "$2" = "${2%/}/" ] || [ -z "$2" ] || die 'Internal error' 298 [ -d "${PREFIX}/$1" ] || die 'Internal error' 299 300 [ -d "${PREFIX}/$2" ] || mkdir -p -- "${PREFIX}/${2%/}" 301 302 for ARG in "${PREFIX}/$1".* "${PREFIX}/$1"*; do 303 SRC="${ARG#"${PREFIX}/"}" 304 DEST="$2$(basename "${ARG}")" 305 306 if [ -f "${ARG}" ]; then 307 do_copy_move_file "${SRC}" "${DEST}" 308 elif [ -d "${ARG}" ] && [ "${ARG}" = "${ARG%/.*}" ] 309 then 310 do_copy_move_dir "${SRC}/" "${DEST}/" 311 fi 312 done 313 314 unset ARG 315 rmdir -p -- "${PREFIX}/$1" 2>/dev/null || true 316 } 317 318 # Copy or move a secret file (depending on ${ACTION}) 319 # $1: source file name 320 # $2: destination file name 321 # ACTION: Copy or Move 322 # DECISION: whether to re-encrypt or copy/move 323 # OVERWRITE: whether to overwrite without confirmation 324 # SCM_ACTION: scm_cp or scm_mv 325 do_copy_move_file() { 326 if [ -e "${PREFIX}/$2" ]; then 327 if ! [ "${OVERWRITE}" = yes ]; then 328 yesno "$2 already exists. Overwrite?" 329 [ "${ANSWER}" = y ] || return 0 330 unset ANSWER 331 fi 332 333 rm -f -- "${PREFIX}/$2" 334 fi 335 336 if [ "$1" = "${1%.age}.age" ]; then 337 case "${DECISION}" in 338 keep) 339 ANSWER=n 340 ;; 341 interactive) 342 yesno "Reencrypt ${1%.age} into ${2%.age}?" 343 ;; 344 default) 345 set_LOCAL_RECIPIENT_FILE "$1" 346 SRC_RCPT="${LOCAL_RECIPIENTS}" 347 set_LOCAL_RECIPIENT_FILE "$2" 348 DST_RCPT="${LOCAL_RECIPIENTS}" 349 350 if [ "${SRC_RCPT}" = "${DST_RCPT}" ]; then 351 ANSWER=n 352 else 353 ANSWER=y 354 fi 355 356 unset DST_RCPT 357 unset SRC_RCPT 358 ;; 359 force) 360 ANSWER=y 361 ;; 362 *) 363 die "Unexpected DECISION value \"${DECISION}\"" 364 ;; 365 esac 366 else 367 ANSWER=n 368 fi 369 370 if [ "${ANSWER}" = y ]; then 371 do_decrypt "${PREFIX}/$1" | do_encrypt "$2" 372 if [ "${ACTION}" = Move ]; then 373 scm_rm "$1" 374 fi 375 scm_add "$2" 376 else 377 "${SCM_ACTION}" "$1" "$2" 378 fi 379 380 unset ANSWER 381 } 382 383 # Decrypt a secret file into standard output 384 # $1: full path of the encrypted file 385 # IDENTITIES_FILE: full path of age identity 386 do_decrypt() { 387 checked "${AGE}" -d -i "${IDENTITIES_FILE}" -- "$1" 388 } 389 390 # Decrypt a GPG secret file into standard output 391 # $1: pull path of the encrypted file 392 # GPG: (optional) gpg command 393 do_decrypt_gpg() { 394 if [ -z "${GPG-}" ]; then 395 if type gpg2 >/dev/null 2>&1; then 396 GPG=gpg2 397 elif type gpg >/dev/null 2>&1; then 398 GPG=gpg 399 else 400 die "GPG does not seem available" 401 fi 402 fi 403 404 set -- -- "$@" 405 if [ -n "${GPG_AGENT_INFO-}" ] || [ "${GPG}" = "gpg2" ]; then 406 set -- "--batch" "--use-agent" "$@" 407 fi 408 set -- "--quiet" \ 409 "--yes" \ 410 "--compress-algo=none" \ 411 "--no-encrypt-to" \ 412 "$@" 413 414 checked "${GPG}" -d "$@" 415 } 416 417 # Remove identities from a subdirectory 418 # $1: relative subdirectory (may be empty) 419 # DECISION: whether to re-encrypt or not 420 do_deinit() { 421 LOC="${1:-store root}" 422 TARGET="${1%/}${1:+/}.age-recipients" 423 424 if ! [ -f "${PREFIX}/${TARGET}" ]; then 425 die "No existing recipient to remove at ${LOC}" 426 fi 427 428 scm_begin 429 scm_rm "${TARGET}" 430 if ! [ "${DECISION}" = keep ]; then 431 RECURSIVE=no 432 do_reencrypt_dir "${PREFIX}/$1" 433 unset RECURSIVE 434 fi 435 scm_commit "Deinitialize ${LOC}" 436 rmdir -p -- "${PREFIX}/$1" 2>/dev/null || true 437 438 unset LOC 439 unset TARGET 440 } 441 442 # Delete a file or directory from the password store 443 # $1: file or directory name 444 # DECISION: whether to ask before deleting 445 # RECURSIVE: whether to delete directories 446 do_delete() { 447 # Distinguish between file or directory 448 if [ "$1" = "${1%/}/" ]; then 449 NAME="$1" 450 TARGET="$1" 451 if ! [ -e "${PREFIX}/${NAME%/}" ]; then 452 die "Error: $1 is not in the password store." 453 fi 454 if ! [ -d "${PREFIX}/${NAME%/}" ]; then 455 die "Error: $1 is not a directory." 456 fi 457 if ! [ "${RECURSIVE}" = yes ]; then 458 die "Error: $1 is a directory" 459 fi 460 elif [ -f "${PREFIX}/$1.age" ]; then 461 NAME="$1" 462 TARGET="$1.age" 463 elif [ -d "${PREFIX}/$1" ]; then 464 if ! [ "${RECURSIVE}" = yes ]; then 465 die "Error: $1/ is a directory" 466 fi 467 NAME="$1/" 468 TARGET="$1/" 469 else 470 die "Error: $1 is not in the password store." 471 fi 472 473 if [ "${DECISION}" = force ]; then 474 printf '%s\n' "Removing ${NAME}" 475 else 476 yesno "Are you sure you would like to delete ${NAME}?" 477 [ "${ANSWER}" = y ] || return 0 478 unset ANSWER 479 fi 480 481 # Perform the deletion 482 scm_begin 483 scm_rm "${TARGET}" 484 scm_commit "Remove ${NAME} from store." 485 rmdir -p -- "$(dirname "${PREFIX}/${TARGET}")" 2>/dev/null || true 486 } 487 488 # Edit a secret interactively 489 # $1: pass-name 490 # EDIT_CMD, EDITOR, VISUAL: editor command 491 do_edit() { 492 NAME="${1#/}" 493 TARGET="${PREFIX}/${NAME}.age" 494 495 TMPNAME="${NAME}" 496 while ! [ "${TMPNAME}" = "${TMPNAME#*/}" ]; do 497 TMPNAME="${TMPNAME%%/*}-${TMPNAME#*/}" 498 done 499 500 TMPFILE="$(mktemp -u "${SECURE_TMPDIR}/XXXXXX")-${TMPNAME}.txt" 501 502 if [ -f "${TARGET}" ]; then 503 ACTION="Edit" 504 do_decrypt "${TARGET}" >"${TMPFILE}" 505 OLD_VALUE="$(cat "${TMPFILE}")" 506 else 507 ACTION="Add" 508 OLD_VALUE= 509 fi 510 511 scm_begin 512 513 if [ -z "${EDIT_CMD-}" ]; then 514 if [ -n "${VISUAL-}" ] && ! [ "${TERM:-dumb}" = dumb ]; then 515 EDIT_CMD="${VISUAL}" 516 elif [ -n "${EDITOR-}" ]; then 517 EDIT_CMD="${EDITOR}" 518 else 519 EDIT_CMD="vi" 520 fi 521 fi 522 523 if ${EDIT_CMD} "${TMPFILE}"; then 524 : 525 else 526 CODE="$?" 527 printf 'Editor "%s" exited with code %s\n' \ 528 "${EDIT_CMD}" "${CODE}" >&2 529 exit "${CODE}" 530 fi 531 532 if ! [ -f "${TMPFILE}" ]; then 533 printf '%s\n' "New password for ${NAME} not saved." 534 elif [ -n "${OLD_VALUE}" ] \ 535 && printf '%s\n' "${OLD_VALUE}" \ 536 | diff -- - "${TMPFILE}" >/dev/null 2>&1 537 then 538 printf '%s\n' "Password for ${NAME} unchanged." 539 rm "${TMPFILE}" 540 else 541 OVERWRITE=once 542 do_encrypt "${NAME}.age" <"${TMPFILE}" 543 scm_add "${NAME}.age" 544 scm_commit "${ACTION} password for ${NAME} using ${EDIT_CMD}." 545 rm "${TMPFILE}" 546 fi 547 548 unset ACTION 549 unset OLD_VALUE 550 unset NAME 551 unset TARGET 552 unset TMPNAME 553 unset TMPFILE 554 } 555 556 # Encrypt a secret on standard input into a file 557 # $1: relative path of the encrypted file 558 do_encrypt() { 559 TARGET="$1" 560 set -- 561 562 if [ -n "${PASHAGE_RECIPIENTS_FILE-}" ]; then 563 set -- "$@" -R "${PASHAGE_RECIPIENTS_FILE}" 564 565 elif [ -n "${PASSAGE_RECIPIENTS_FILE-}" ]; then 566 set -- "$@" -R "${PASSAGE_RECIPIENTS_FILE}" 567 568 elif [ -n "${PASHAGE_RECIPIENTS-}" ]; then 569 for ARG in ${PASHAGE_RECIPIENTS}; do 570 set -- "$@" -r "${ARG}" 571 done 572 unset ARG 573 574 elif [ -n "${PASSAGE_RECIPIENTS-}" ]; then 575 for ARG in ${PASSAGE_RECIPIENTS}; do 576 set -- "$@" -r "${ARG}" 577 done 578 unset ARG 579 580 else 581 set_LOCAL_RECIPIENT_FILE "${TARGET}" 582 583 if [ -n "${LOCAL_RECIPIENT_FILE}" ]; then 584 set -- "$@" -R "${LOCAL_RECIPIENT_FILE}" 585 else 586 set -- "$@" -i "${IDENTITIES_FILE}" 587 fi 588 fi 589 590 unset LOCAL_RECIPIENT_FILE 591 592 if [ -e "${PREFIX}/${TARGET}" ] && ! [ "${OVERWRITE}" = yes ]; then 593 if [ "${OVERWRITE}" = once ]; then 594 OVERWRITE=no 595 else 596 die "Refusing to overwite ${TARGET}" 597 fi 598 fi 599 mkdir -p "$(dirname "${PREFIX}/${TARGET}")" 600 "${AGE}" -e "$@" -o "${PREFIX}/${TARGET}" 601 unset TARGET 602 } 603 604 # Generate a new secret 605 # $1: secret name 606 # $2: new password length 607 # $3: new password charset 608 # DECISION: when interactive, show-ask-commit instead of commit-show 609 # OVERWRITE: whether to overwrite with confirmation ("no"), without 610 # confirmation ("yes"), or with existing secret data ("reuse") 611 # SELECTED_LINE: which line to paste or diplay as qr-code 612 # SHOW: how to show the secret 613 do_generate() { 614 NEW_PASS="$(random_chars "$2" "$3")" 615 NEW_PASS_LEN="$(strlen "${NEW_PASS}")" 616 617 if [ "${NEW_PASS_LEN}" -ne "$2" ]; then 618 die "Error while generating password:" \ 619 "${NEW_PASS_LEN}/$2 bytes read" 620 fi 621 unset NEW_PASS_LEN 622 623 if [ "${DECISION}" = interactive ]; then 624 do_generate_show "$@" 625 yesno "Save generated password for $1?" 626 [ "${ANSWER}" = y ] && do_generate_commit "$@" 627 else 628 do_generate_commit "$@" 629 [ "${ANSWER-y}" = y ] && do_generate_show "$@" 630 fi 631 632 unset NEW_PASS 633 } 634 635 # SCM-committing part of do_generate 636 do_generate_commit() { 637 scm_begin 638 mkdir -p -- "$(dirname "${PREFIX}/$1.age")" 639 EXTRA= 640 641 if [ -d "${PREFIX}/$1.age" ]; then 642 die "Cannot replace directory $1.age" 643 644 elif [ -e "${PREFIX}/$1.age" ] && [ "${OVERWRITE}" = reuse ]; then 645 printf '%s\n' "Decrypting previous secret for $1" 646 OLD_SECRET_FULL="$(do_decrypt "${PREFIX}/$1.age")" 647 OLD_SECRET="${OLD_SECRET_FULL#*"${NL}"}" 648 if ! [ "${OLD_SECRET}" = "${OLD_SECRET_FULL}" ]; then 649 EXTRA="${OLD_SECRET}" 650 fi 651 unset OLD_SECRET 652 unset OLD_SECRET_FULL 653 OVERWRITE=once 654 VERB="Replace" 655 656 else 657 if [ -e "${PREFIX}/$1.age" ] && ! [ "${OVERWRITE}" = yes ]; then 658 yesno "An entry already exists for $1. Overwrite it?" 659 [ "${ANSWER}" = y ] || return 0 660 unset ANSWER 661 OVERWRITE=once 662 fi 663 664 VERB="Add" 665 fi 666 667 if [ "${MULTILINE}" = yes ]; then 668 echo 'Enter extra secrets then Ctrl+D when finished:' 669 while IFS='' read -r LINE; do 670 EXTRA="${EXTRA}${EXTRA:+${NL}}${LINE}" 671 done 672 fi 673 674 do_encrypt "$1.age" <<-EOF 675 ${NEW_PASS}${EXTRA:+${NL}}${EXTRA} 676 EOF 677 678 unset EXTRA 679 680 scm_add "${PREFIX}/$1.age" 681 scm_commit "${VERB} generated password for $1." 682 683 unset VERB 684 } 685 686 # Showing part of do_generate 687 do_generate_show() { 688 if [ "${SHOW}" = text ]; then 689 printf '%sThe generated password for %s%s%s is:%s\n' \ 690 "${BOLD_TEXT}" \ 691 "${UNDERLINE_TEXT}" \ 692 "$1" \ 693 "${NO_UNDERLINE_TEXT}" \ 694 "${NORMAL_TEXT}" 695 fi 696 697 do_show "$1" <<-EOF 698 ${NEW_PASS} 699 EOF 700 } 701 702 # Recursively grep decrypted secrets in current directory 703 # $1: current subdirectory name 704 # ... grep arguments 705 do_grep() { 706 for ARG in "$1"/*; do 707 if [ -d "${ARG}" ]; then 708 shift 709 set -- "${ARG}" "$@" 710 do_grep "$@" 711 elif [ "${ARG}" = "${ARG%.age}.age" ]; then 712 HEADER="${ARG%/*}" 713 HEADER="${HEADER#"${PREFIX}"}" 714 HEADER="${HEADER#/}" 715 HEADER="${BLUE_TEXT}${HEADER}${HEADER:+/}" 716 HEADER="${HEADER}${BOLD_TEXT}${ARG##*/}" 717 HEADER="${HEADER%.age}${NORMAL_TEXT}:" 718 SECRET="$(do_decrypt "${ARG}")" 719 shift 720 do_grep_filter "$@" <<-EOF 721 ${SECRET} 722 EOF 723 set -- a "$@" 724 fi 725 done 726 727 unset ARG 728 unset HEADER 729 } 730 731 # Wrapper around grep filter to add a header when a match is found 732 # ... grep arguments 733 # HEADER header to print before matches, if any 734 do_grep_filter() { 735 unset SECRET 736 737 grep_filter "$@" | while IFS= read -r LINE; do 738 [ -n "${HEADER}" ] && printf '%s\n' "${HEADER}" 739 printf '%s\n' "${LINE}" 740 HEADER='' 741 done 742 } 743 744 # Add identities to a subdirectory 745 # $1: relative subdirectory (may be empty) 746 # ... identities 747 # DECISION: whether to re-encrypt or not 748 do_init() { 749 LOC="${1:-store root}" 750 SUBDIR="${PREFIX}${1:+/}${1%/}" 751 TARGET="${SUBDIR}/.age-recipients" 752 shift 753 754 mkdir -p -- "${SUBDIR}" 755 756 scm_begin 757 758 if ! [ -f "${TARGET}" ] || [ "${OVERWRITE}" = yes ]; then 759 : >|"${TARGET}" 760 fi 761 762 printf '%s\n' "$@" >>"${TARGET}" 763 scm_add "${TARGET#"${PREFIX}/"}" 764 if ! [ "${DECISION}" = keep ]; then 765 RECURSIVE=no 766 do_reencrypt_dir "${SUBDIR}" 767 unset RECURSIVE 768 fi 769 scm_commit "Set age recipients at ${LOC}" 770 printf '%s\n' "Password store recipients set at ${LOC}" 771 772 unset LOC 773 unset TARGET 774 unset SUBDIR 775 } 776 777 # Insert a new secret from standard input 778 # $1: entry name 779 # ECHO: whether interactive echo is kept 780 # MULTILINE: whether whole standard input is used 781 # OVERWRITE: whether to overwrite without confirmation 782 do_insert() { 783 if [ -e "${PREFIX}/$1.age" ] && [ "${OVERWRITE}" = no ]; then 784 yesno "An entry already exists for $1. Overwrite it?" 785 [ "${ANSWER}" = y ] || return 0 786 unset ANSWER 787 OVERWRITE=once 788 fi 789 790 scm_begin 791 mkdir -p -- "$(dirname "${PREFIX}/$1.age")" 792 793 if [ "${MULTILINE}" = yes ]; then 794 printf '%s\n' \ 795 "Enter contents of $1 and" \ 796 "press Ctrl+D or enter an empty line when finished:" 797 while IFS= read -r LINE; do 798 if [ -n "${LINE}" ]; then 799 printf '%s\n' "${LINE}" 800 else 801 break 802 fi 803 done | do_encrypt "$1.age" 804 805 elif [ "${ECHO}" = yes ] \ 806 || ! type stty >/dev/null 2>&1 \ 807 || ! stty >/dev/null 2>&1 808 then 809 printf 'Enter password for %s: ' "$1" 810 IFS= read -r LINE 811 do_encrypt "$1.age" <<-EOF 812 ${LINE} 813 EOF 814 unset LINE 815 816 else 817 while true; do 818 printf 'Enter password for %s: ' "$1" 819 stty -echo 820 read -r LINE1 821 printf '\nRetype password for %s: ' "$1" 822 read -r LINE2 823 stty echo 824 printf '\n' 825 826 if [ "${LINE1}" = "${LINE2}" ]; then 827 break 828 else 829 unset LINE1 LINE2 830 echo "Passwords don't match" 831 fi 832 done 833 834 do_encrypt "$1.age" <<-EOF 835 ${LINE1} 836 EOF 837 unset LINE1 LINE2 838 fi 839 840 scm_add "$1.age" 841 scm_commit "Add given password for $1 to store." 842 } 843 844 # Display the entry list rooted at the given relative directory 845 # $1: path relative to prefix 846 # ...: (optional) grep arguments to filter 847 # BEGIN_GPG_NAME: marker before gpg secret name 848 # END_GPG_NAME: marker after gpg secret name 849 # HAS_ITEMS: (output) set to `yes` when something has been printed 850 # LIST_EMPTY: include empty directories in output when set to `yes` 851 # Note that this function is recrusive and cannot use variables to hold state 852 # (except for HAS_ITEMS which is carefully designed with this constraint). 853 do_list() { 854 for FULL_ENTRY in "${PREFIX}/$1${1:+/}"*; do 855 ENTRY="${FULL_ENTRY#"${PREFIX}/"}" 856 ITEM_NAME="${ENTRY##*/}" 857 if [ -d "${FULL_ENTRY}" ]; then 858 shift 859 set -- "${ENTRY}" "$@" 860 HAS_ITEMS=no 861 do_list "$@" 862 if [ "${LIST_EMPTY}${HAS_ITEMS}" = 'yesno' ]; then 863 printf '%s/\n' "$1" 864 fi 865 HAS_ITEMS=yes 866 elif [ "${ENTRY%.age}.age" = "${ENTRY}" ]; then 867 shift 868 set -- "-q" "$@" 869 if [ $# -le 1 ] \ 870 || printf '%s\n' "${ITEM_NAME%.age}" | grep "$@" 871 then 872 printf '%s\n' "${ENTRY%.age}" 873 HAS_ITEMS=yes 874 fi 875 elif [ "${ENTRY%.gpg}.gpg" = "${ENTRY}" ]; then 876 shift 877 set -- "-q" "$@" 878 if [ $# -le 1 ] \ 879 || printf '%s\n' "${ITEM_NAME%.gpg}" | grep "$@" 880 then 881 if ! [ "${ENTRY}" = "${ITEM_NAME}" ]; then 882 printf '%s' "${ENTRY%/*}/" 883 fi 884 if ! [ -d "${FULL_ENTRY%.gpg}" ] \ 885 && ! [ -f "${FULL_ENTRY%.gpg}.age" ] 886 then 887 ITEM_NAME="${ITEM_NAME%.gpg}" 888 fi 889 printf '%s%s%s\n' \ 890 "${BEGIN_GPG_NAME}" \ 891 "${ITEM_NAME}" \ 892 "${END_GPG_NAME}" 893 HAS_ITEMS=yes 894 fi 895 fi 896 unset ENTRY 897 unset ITEM_NAME 898 done 899 unset FULL_ENTRY 900 } 901 902 # Display a single directory or entry 903 # $1: entry name 904 # LIST_VIEW: whether directories are displayed as a list rather then a tree 905 do_list_or_show() { 906 if [ -z "$1" ]; then 907 if [ "${LIST_VIEW-no}" = "yes" ]; then 908 BEGIN_GPG_NAME='' 909 END_GPG_NAME='' 910 LIST_EMPTY='no' 911 do_list '' 912 unset BEGIN_GPG_NAME 913 unset END_GPG_NAME 914 unset LIST_EMPTY 915 else 916 printf 'Password Store\n' 917 do_tree '' 918 fi 919 elif [ -f "${PREFIX}/$1.age" ]; then 920 SECRET="$(do_decrypt "${PREFIX}/$1.age")" 921 do_show "$1" <<-EOF 922 ${SECRET} 923 EOF 924 unset SECRET 925 elif [ -d "${PREFIX}/$1" ]; then 926 if [ "${LIST_VIEW-no}" = "yes" ]; then 927 BEGIN_GPG_NAME='' 928 END_GPG_NAME='' 929 LIST_EMPTY='no' 930 do_list "${1%/}" 931 unset BEGIN_GPG_NAME 932 unset END_GPG_NAME 933 unset LIST_EMPTY 934 else 935 printf '%s\n' "${1%/}" 936 do_tree "${1%/}" 937 fi 938 elif [ -f "${PREFIX}/$1.gpg" ]; then 939 SECRET="$(do_decrypt_gpg "${PREFIX}/$1.gpg")" 940 do_show "$1" <<-EOF 941 ${SECRET} 942 EOF 943 unset SECRET 944 else 945 die "Error: $1 is not in the password store." 946 fi 947 } 948 949 # Re-encrypts a file or a directory 950 # $1: entry name 951 # DECISION: whether to ask before re-encryption 952 # RECURSIVE: whether to also re-encrypt subfolders with their own recipients 953 do_reencrypt() { 954 scm_begin 955 956 if [ "$1" = "${1%/}/" ]; then 957 if ! [ -d "${PREFIX}/${1%/}" ]; then 958 die "Error: $1 is not in the password store." 959 fi 960 do_reencrypt_dir "${PREFIX}/${1%/}" 961 LOC="$1" 962 963 elif [ -f "${PREFIX}/$1.age" ]; then 964 do_reencrypt_file "$1" 965 LOC="$1" 966 967 elif [ -d "${PREFIX}/$1" ]; then 968 do_reencrypt_dir "${PREFIX}/$1" 969 LOC="$1/" 970 971 else 972 die "Error: $1 is not in the password store." 973 fi 974 975 scm_commit "Re-encrypt ${LOC}" 976 unset LOC 977 } 978 979 # Recursively re-encrypts a directory 980 # $1: absolute directory path 981 # DECISION: whether to ask before re-encryption 982 # RECURSIVE: whether to also re-encrypt subfolders with their own recipients 983 do_reencrypt_dir() { 984 for ENTRY in "${1%/}"/*; do 985 if [ -d "${ENTRY}" ]; then 986 if ! [ -e "${ENTRY}/.age-recipients" ] \ 987 || [ "${RECURSIVE}" = yes ] 988 then 989 do_reencrypt_dir "${ENTRY}" 990 fi 991 elif [ "${ENTRY}" = "${ENTRY%.age}.age" ]; then 992 ENTRY="${ENTRY#"${PREFIX}"/}" 993 do_reencrypt_file "${ENTRY%.age}" 994 fi 995 done 996 } 997 998 # Re-encrypts a file 999 # $1: entry name 1000 # DECISION: whether to ask before re-encryption 1001 do_reencrypt_file() { 1002 if [ "${DECISION}" = interactive ]; then 1003 yesno "Re-encrypt $1?" 1004 [ "${ANSWER}" = y ] || return 0 1005 unset ANSWER 1006 fi 1007 1008 OVERWRITE=once 1009 WIP_FILE="$(mktemp -u "${PREFIX}/$1-XXXXXXXXX.age")" 1010 SECRET="$(do_decrypt "${PREFIX}/$1.age")" 1011 do_encrypt "${WIP_FILE#"${PREFIX}"/}" <<-EOF 1012 ${SECRET} 1013 EOF 1014 mv -f -- "${WIP_FILE}" "${PREFIX}/$1.age" 1015 unset WIP_FILE 1016 scm_add "$1.age" 1017 } 1018 1019 # Display a decrypted secret from standard input 1020 # $1: title 1021 # SELECTED_LINE: which line to paste or diplay as qr-code 1022 # SHOW: how to show the secret 1023 do_show() { 1024 unset SECRET 1025 1026 case "${SHOW}" in 1027 text) 1028 cat 1029 ;; 1030 clip) 1031 tail -n "+${SELECTED_LINE}" \ 1032 | head -n 1 \ 1033 | tr -d '\n' \ 1034 | platform_clip "$1" 1035 ;; 1036 qrcode) 1037 tail -n "+${SELECTED_LINE}" \ 1038 | head -n 1 \ 1039 | tr -d '\n' \ 1040 | platform_qrcode "$1" 1041 ;; 1042 *) 1043 die "Unexpected SHOW value \"${SHOW}\"" 1044 ;; 1045 esac 1046 } 1047 1048 # Display a list of secret as a tree 1049 # $1: path relative to prefix 1050 # ...: (optional) grep arguments to filter 1051 do_tree() { 1052 REVERSE="" 1053 BEGIN_GPG_NAME="${RED_TEXT}" 1054 END_GPG_NAME="${NORMAL_TEXT}" 1055 LIST_EMPTY="${2+no}" 1056 LIST_EMPTY="${LIST_EMPTY:-yes}" 1057 ENTRY_LIST="$(do_list "$@")" 1058 while read -r LINE; do 1059 REVERSE="${LINE#"${1-}${1:+/}"}${REVERSE:+"${NL}"}${REVERSE}" 1060 done <<-EOF 1061 ${ENTRY_LIST} 1062 EOF 1063 unset BEGIN_GPG_NAME 1064 unset END_GPG_NAME 1065 unset LIST_EMPTY 1066 unset ENTRY_LIST 1067 1068 GRAPH="_" 1069 TREE="" 1070 PREV_LINE="" 1071 while read -r LINE; do 1072 LINE_COPY="${LINE}" 1073 # Skip common directory prefix 1074 while ! [ "${LINE_COPY%%/*}" = "${LINE_COPY}" ] \ 1075 && ! [ "${PREV_LINE%%/*}" = "${PREV_LINE}" ] \ 1076 && [ "${LINE_COPY%%/*}" = "${PREV_LINE%%/*}" ] 1077 do 1078 LINE_COPY="${LINE_COPY#*/}" 1079 PREV_LINE="${PREV_LINE#*/}" 1080 done 1081 1082 # Output obsolete directories 1083 while ! [ "${PREV_LINE%/*}" = "${PREV_LINE}" ]; do 1084 PREV_LINE="${PREV_LINE%/*}" 1085 do_tree_prefix "${GRAPH%?}" 1086 GRAPH="${GRAPH%??}I" 1087 TREE="${PREV_LINE##*/}${NORMAL_TEXT}${NL}${TREE}" 1088 TREE="${CGRAPH}${BLUE_TEXT}${TREE}" 1089 unset CGRAPH 1090 done 1091 1092 [ -z "${LINE}" ] && continue 1093 1094 # Prepare new directories 1095 while ! [ "${LINE_COPY%/*}" = "${LINE_COPY}" ]; do 1096 LINE_COPY="${LINE_COPY%/*}" 1097 GRAPH="${GRAPH}_" 1098 done 1099 unset LINE_COPY 1100 1101 if [ -n "${LINE##*/}" ]; then 1102 do_tree_prefix "${GRAPH}" 1103 GRAPH="${GRAPH%?}I" 1104 TREE="${CGRAPH}${LINE##*/}${NL}${TREE}" 1105 unset CGRAPH 1106 fi 1107 1108 PREV_LINE="${LINE}" 1109 done <<-EOF 1110 ${REVERSE} 1111 1112 EOF 1113 # Note the extra blank line above to flush the first directories 1114 unset GRAPH 1115 unset LINE 1116 unset PREV_LINE 1117 1118 printf '%s' "${TREE}" 1119 unset TREE 1120 } 1121 1122 1123 # Convert a tree prefix into user-facing representation 1124 # $1: encoded tree prefix 1125 # CGRAPH: output user-facing representation 1126 do_tree_prefix() { 1127 CGRAPH="" 1128 while [ -n "${1#?}" ]; do 1129 case "${1%"${1#?}"}" in 1130 (_) 1131 CGRAPH="${CGRAPH}${TREE__}" 1132 ;; 1133 (I) 1134 CGRAPH="${CGRAPH}${TREE_I}" 1135 ;; 1136 (*) 1137 die "Invalid tree prefix: \"$1\"" 1138 ;; 1139 esac 1140 set -- "${1#?}" 1141 done 1142 1143 case "$1" in 1144 (_) 1145 CGRAPH="${CGRAPH}${TREE_L}" 1146 ;; 1147 (I) 1148 CGRAPH="${CGRAPH}${TREE_T}" 1149 ;; 1150 (*) 1151 die "Invalid tree prefix: \"$1\"" 1152 ;; 1153 esac 1154 } 1155 1156 1157 ############ 1158 # COMMANDS # 1159 ############ 1160 1161 cmd_copy() { 1162 ACTION=Copy 1163 SCM_ACTION=scm_cp 1164 cmd_copy_move "$@" 1165 } 1166 1167 cmd_copy_move() { 1168 DECISION=default 1169 OVERWRITE=no 1170 PARSE_ERROR=no 1171 1172 while [ $# -ge 1 ]; do 1173 case "$1" in 1174 -f|--force) 1175 OVERWRITE=yes 1176 shift ;; 1177 -e|--reencrypt) 1178 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1179 DECISION=force 1180 shift ;; 1181 -i|--interactive) 1182 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1183 DECISION=interactive 1184 shift ;; 1185 -k|--keep) 1186 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1187 DECISION=keep 1188 shift ;; 1189 -[efik]?*) 1190 REST="${1#??}" 1191 FIRST="${1%"${REST}"}" 1192 shift 1193 set -- "${FIRST}" "-${REST}" "$@" 1194 unset FIRST 1195 unset REST 1196 ;; 1197 --) 1198 shift 1199 break ;; 1200 -*) 1201 PARSE_ERROR=yes 1202 break ;; 1203 *) 1204 break ;; 1205 esac 1206 done 1207 1208 if [ "${PARSE_ERROR}" = yes ] || [ $# -lt 2 ]; then 1209 if [ "${COMMAND}" = "c${COMMAND#c}" ]; then 1210 cmd_usage no 'Usage: ' copy >&2 1211 exit 1 1212 elif [ "${COMMAND}" = "m${COMMAND#m}" ]; then 1213 cmd_usage no 'Usage: ' move >&2 1214 exit 1 1215 else 1216 cmd_usage no 'Usage: ' copy move >&2 1217 exit 1 1218 fi 1219 fi 1220 unset PARSE_ERROR 1221 1222 check_sneaky_paths "$@" 1223 1224 if [ $# -gt 2 ]; then 1225 SHARED_DEST="$1" 1226 shift 1227 for ARG in "$@"; do 1228 shift 1229 set -- "$@" "${SHARED_DEST}" 1230 SHARED_DEST="${ARG}" 1231 done 1232 1233 for ARG in "$@"; do 1234 do_copy_move "${ARG}" "${SHARED_DEST%/}/" 1235 done 1236 else 1237 do_copy_move "$@" 1238 fi 1239 } 1240 1241 cmd_delete() { 1242 DECISION=default 1243 PARSE_ERROR=no 1244 RECURSIVE=no 1245 1246 while [ $# -ge 1 ]; do 1247 case "$1" in 1248 -f|--force) 1249 DECISION=force 1250 shift ;; 1251 -r|--recursive) 1252 RECURSIVE=yes 1253 shift ;; 1254 -[fr]?*) 1255 REST="${1#??}" 1256 FIRST="${1%"${REST}"}" 1257 shift 1258 set -- "${FIRST}" "-${REST}" "$@" 1259 unset FIRST 1260 unset REST 1261 ;; 1262 --) 1263 shift 1264 break ;; 1265 -*) 1266 PARSE_ERROR=yes 1267 break ;; 1268 *) 1269 break ;; 1270 esac 1271 done 1272 1273 if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then 1274 cmd_usage no 'Usage: ' delete >&2 1275 exit 1 1276 fi 1277 unset PARSE_ERROR 1278 1279 check_sneaky_paths "$@" 1280 1281 for ARG in "$@"; do 1282 do_delete "${ARG}" 1283 done 1284 } 1285 1286 cmd_edit() { 1287 if [ $# -eq 0 ]; then 1288 cmd_usage no 'Usage: ' edit >&2 1289 exit 1 1290 fi 1291 1292 check_sneaky_paths "$@" 1293 platform_tmpdir 1294 1295 for ARG in "$@"; do 1296 do_edit "${ARG}" 1297 done 1298 } 1299 1300 cmd_find() { 1301 LIST_VIEW=no 1302 case "${1-}" in 1303 -r|--raw) 1304 LIST_VIEW=yes 1305 shift ;; 1306 *) 1307 ;; 1308 esac 1309 1310 if [ $# -eq 0 ]; then 1311 cmd_usage no 'Usage: ' find >&2 1312 exit 1 1313 fi 1314 1315 if [ "${LIST_VIEW}" = yes ]; then 1316 BEGIN_GPG_NAME='' 1317 END_GPG_NAME='' 1318 LIST_EMPTY='no' 1319 do_list '' "$@" 1320 unset BEGIN_GPG_NAME 1321 unset END_GPG_NAME 1322 unset LIST_EMPTY 1323 else 1324 printf 'Search pattern: %s\n' "$*" 1325 do_tree '' "$@" 1326 fi 1327 } 1328 1329 cmd_generate() { 1330 CHARSET="${CHARACTER_SET}" 1331 DECISION=default 1332 MULTILINE=no 1333 OVERWRITE=no 1334 PARSE_ERROR=no 1335 SELECTED_LINE=1 1336 SHOW=text 1337 1338 while [ $# -ge 1 ]; do 1339 case "$1" in 1340 -c|--clip) 1341 if ! [ "${SHOW}" = text ]; then 1342 PARSE_ERROR=yes 1343 break 1344 fi 1345 SHOW=clip 1346 shift ;; 1347 -f|--force) 1348 if ! [ "${OVERWRITE}" = no ]; then 1349 PARSE_ERROR=yes 1350 break 1351 fi 1352 OVERWRITE=yes 1353 shift ;; 1354 -i|--in-place) 1355 if ! [ "${OVERWRITE}" = no ]; then 1356 PARSE_ERROR=yes 1357 break 1358 fi 1359 OVERWRITE=reuse 1360 shift ;; 1361 -m|--multiline) 1362 MULTILINE=yes 1363 shift ;; 1364 -n|--no-symbols) 1365 CHARSET="${CHARACTER_SET_NO_SYMBOLS}" 1366 shift ;; 1367 -q|--qrcode) 1368 if ! [ "${SHOW}" = text ]; then 1369 PARSE_ERROR=yes 1370 break 1371 fi 1372 SHOW=qrcode 1373 shift ;; 1374 -t|--try) 1375 DECISION=interactive 1376 shift ;; 1377 -[cfimnqt]?*) 1378 REST="${1#-?}" 1379 ARG="${1%"${REST}"}" 1380 shift 1381 set -- "${ARG}" "-${REST}" "$@" 1382 unset ARG 1383 unset REST 1384 ;; 1385 --) 1386 shift 1387 break ;; 1388 -*) 1389 PARSE_ERROR=yes 1390 break ;; 1391 *) 1392 break ;; 1393 esac 1394 done 1395 1396 if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ] || [ $# -gt 3 ]; then 1397 cmd_usage no 'Usage: ' generate >&2 1398 exit 1 1399 fi 1400 1401 unset PARSE_ERROR 1402 1403 check_sneaky_path "$1" 1404 LENGTH="${2:-${GENERATED_LENGTH}}" 1405 [ -n "${LENGTH##*[!0-9]*}" ] \ 1406 || die "Error: passlength \"${LENGTH}\" must be a number." 1407 [ "${LENGTH}" -gt 0 ] \ 1408 || die "Error: pass-length must be greater than zero." 1409 1410 do_generate "$1" "${LENGTH}" "${3:-${CHARSET}}" 1411 1412 unset CHARSET 1413 unset LENGTH 1414 } 1415 1416 cmd_git() { 1417 if [ -d "${PREFIX}/.git" ]; then 1418 platform_tmpdir 1419 TMPDIR="${SECURE_TMPDIR}" git -C "${PREFIX}" "$@" 1420 elif [ "${1-}" = init ]; then 1421 mkdir -p -- "${PREFIX}" 1422 git -C "${PREFIX}" "$@" 1423 scm_add '.' 1424 scm_commit "Add current contents of password store." 1425 cmd_gitconfig 1426 elif [ "${1-}" = clone ]; then 1427 git "$@" "${PREFIX}" 1428 cmd_gitconfig 1429 else 1430 die "Error: the password store is not a git repository." \ 1431 "Try \"${PROGRAM} git init\"." 1432 fi 1433 } 1434 1435 cmd_grep() { 1436 if [ $# -eq 0 ]; then 1437 cmd_usage no 'Usage: ' grep >&2 1438 exit 1 1439 fi 1440 1441 do_grep "${PREFIX}" "$@" 1442 } 1443 1444 cmd_gitconfig() { 1445 [ -d "${PREFIX}/.git" ] || die "The store is not a git repository." 1446 1447 if ! [ -f "${PREFIX}/.gitattributes" ] || 1448 ! grep -Fqx '*.age diff=age' "${PREFIX}/.gitattributes" 1449 then 1450 scm_begin 1451 printf '*.age diff=age\n' >>"${PREFIX}/.gitattributes" 1452 scm_add ".gitattributes" 1453 scm_commit "Configure git repository for age file diff." 1454 fi 1455 1456 git -C "${PREFIX}" config --local diff.age.binary true 1457 git -C "${PREFIX}" config --local diff.age.textconv \ 1458 "${AGE} -d -i ${IDENTITIES_FILE}" 1459 } 1460 1461 cmd_help() { 1462 if [ $# -eq 0 ]; then 1463 cmd_version 1464 printf '\nUsage:\n' 1465 cmd_usage yes ' ' "$@" 1466 else 1467 cmd_usage yes '' "$@" 1468 fi 1469 } 1470 1471 cmd_init() { 1472 DECISION=default 1473 OVERWRITE=yes 1474 PARSE_ERROR=no 1475 SUBDIR='' 1476 1477 while [ $# -ge 1 ]; do 1478 case "$1" in 1479 -i|--interactive) 1480 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1481 DECISION=interactive 1482 shift ;; 1483 1484 -k|--keep) 1485 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1486 DECISION=keep 1487 shift ;; 1488 1489 -p|--path) 1490 if [ $# -lt 2 ]; then 1491 PARSE_ERROR=yes 1492 break 1493 fi 1494 1495 SUBDIR="$2" 1496 shift 2 ;; 1497 1498 -p?*) 1499 SUBDIR="${1#-p}" 1500 shift ;; 1501 1502 --path=*) 1503 SUBDIR="${1#--path=}" 1504 shift ;; 1505 1506 -[ik]?*) 1507 REST="${1#-?}" 1508 ARG="${1%"${REST}"}" 1509 shift 1510 set -- "${ARG}" "-${REST}" "$@" 1511 unset ARG 1512 unset REST 1513 ;; 1514 1515 --) 1516 shift 1517 break ;; 1518 1519 -*) 1520 PARSE_ERROR=yes 1521 break ;; 1522 1523 *) 1524 break ;; 1525 esac 1526 done 1527 1528 if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then 1529 cmd_usage no 'Usage: ' init >&2 1530 exit 1 1531 fi 1532 1533 check_sneaky_path "${SUBDIR}" 1534 1535 if [ $# -eq 1 ] && [ -z "$1" ]; then 1536 do_deinit "${SUBDIR}" 1537 else 1538 do_init "${SUBDIR}" "$@" 1539 fi 1540 1541 unset PARSE_ERROR 1542 unset SUBDIR 1543 } 1544 1545 cmd_insert() { 1546 ECHO=no 1547 MULTILINE=no 1548 OVERWRITE=no 1549 PARSE_ERROR=no 1550 1551 while [ $# -ge 1 ]; do 1552 case "$1" in 1553 -e|--echo) 1554 ECHO=yes 1555 shift ;; 1556 -f|--force) 1557 OVERWRITE=yes 1558 shift ;; 1559 -m|--multiline) 1560 MULTILINE=yes 1561 shift ;; 1562 -[efm]?*) 1563 REST="${1#-?}" 1564 ARG="${1%"${REST}"}" 1565 shift 1566 set -- "${ARG}" "-${REST}" "$@" 1567 unset ARG 1568 unset REST 1569 ;; 1570 --) 1571 shift 1572 break ;; 1573 -?*) 1574 PARSE_ERROR=yes 1575 break ;; 1576 *) 1577 break ;; 1578 esac 1579 done 1580 1581 if [ "${PARSE_ERROR}" = yes ] \ 1582 || [ $# -lt 1 ] \ 1583 || [ "${ECHO}${MULTILINE}" = yesyes ] 1584 then 1585 cmd_usage no 'Usage: ' insert >&2 1586 exit 1 1587 fi 1588 unset PARSE_ERROR 1589 1590 check_sneaky_paths "$@" 1591 1592 for ARG in "$@"; do 1593 do_insert "${ARG}" 1594 done 1595 unset ARG 1596 } 1597 1598 cmd_list_or_show() { 1599 LIST_VIEW=no 1600 PARSE_ERROR=no 1601 SELECTED_LINE=1 1602 USE_CLIP=no 1603 USE_QRCODE=no 1604 1605 while [ $# -ge 1 ]; do 1606 case "$1" in 1607 -c|--clip) 1608 USE_CLIP=yes 1609 shift ;; 1610 -c?*) 1611 SELECTED_LINE="${1#-c}" 1612 USE_CLIP=yes 1613 shift ;; 1614 --clip=*) 1615 SELECTED_LINE="${1#--clip=}" 1616 USE_CLIP=yes 1617 shift ;; 1618 -q|--qrcode) 1619 USE_QRCODE=yes 1620 shift ;; 1621 -q?*) 1622 SELECTED_LINE="${1#-q}" 1623 USE_QRCODE=yes 1624 shift ;; 1625 --qrcode=*) 1626 SELECTED_LINE="${1#--qrcode=}" 1627 USE_QRCODE=yes 1628 shift ;; 1629 -r|--raw) 1630 LIST_VIEW=yes 1631 shift ;; 1632 --) 1633 shift 1634 break ;; 1635 -*) 1636 PARSE_ERROR=yes 1637 break ;; 1638 *) 1639 break ;; 1640 esac 1641 done 1642 1643 case "${USE_CLIP}-${USE_QRCODE}" in 1644 no-no) 1645 SHOW=text 1646 ;; 1647 yes-no) 1648 SHOW=clip 1649 ;; 1650 no-yes) 1651 SHOW=qrcode 1652 ;; 1653 *) 1654 PARSE_ERROR=yes 1655 ;; 1656 esac 1657 1658 if [ "${PARSE_ERROR}" = yes ]; then 1659 if [ "${COMMAND}" = "l${COMMAND#l}" ]; then 1660 cmd_usage no 'Usage: ' list >&2 1661 exit 1 1662 elif [ "${COMMAND}" = "s${COMMAND#s}" ]; then 1663 cmd_usage no 'Usage: ' show >&2 1664 exit 1 1665 else 1666 cmd_usage no 'Usage: ' list show >&2 1667 exit 1 1668 fi 1669 fi 1670 unset PARSE_ERROR 1671 1672 check_sneaky_paths "$@" 1673 1674 if [ $# -eq 0 ]; then 1675 do_list_or_show "" 1676 else 1677 for ARG in "$@"; do 1678 do_list_or_show "${ARG}" 1679 done 1680 fi 1681 1682 unset ARG 1683 unset PARSING 1684 } 1685 1686 cmd_move() { 1687 ACTION=Move 1688 SCM_ACTION=scm_mv 1689 cmd_copy_move "$@" 1690 } 1691 1692 cmd_random() { 1693 if [ $# -gt 2 ]; then 1694 cmd_usage no 'Usage: ' random >&2 1695 exit 1 1696 fi 1697 1698 random_chars "${1:-${GENERATED_LENGTH}}" "${2:-${CHARACTER_SET}}" 1699 } 1700 1701 cmd_reencrypt() { 1702 DECISION=default 1703 OVERWRITE=yes 1704 PARSE_ERROR=no 1705 RECURSIVE=no 1706 1707 while [ $# -ge 1 ]; do 1708 case "$1" in 1709 -d|--deep) 1710 RECURSIVE=yes 1711 shift ;; 1712 -i|--interactive) 1713 DECISION=interactive 1714 shift ;; 1715 --) 1716 shift 1717 break ;; 1718 -[di]?*) 1719 REST="${1#??}" 1720 FIRST="${1%"${REST}"}" 1721 shift 1722 set -- "${FIRST}" "-${REST}" "$@" 1723 unset FIRST 1724 unset REST 1725 ;; 1726 -*) 1727 PARSE_ERROR=yes 1728 break ;; 1729 *) 1730 break ;; 1731 esac 1732 done 1733 1734 if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then 1735 cmd_usage no 'Usage: ' reencrypt >&2 1736 exit 1 1737 fi 1738 1739 unset PARSE_ERROR 1740 1741 check_sneaky_paths "$@" 1742 1743 for ARG in "$@"; do 1744 do_reencrypt "${ARG}" 1745 done 1746 unset ARG 1747 } 1748 1749 # Outputs the whole usage text 1750 # $1: output help text after command syntax when "yes" 1751 # $2: indentation 1752 # ... commands to document 1753 cmd_usage(){ 1754 VERBOSE="${1-yes}" 1755 shift 1756 1757 if [ $# -eq 0 ]; then 1758 F=' ' 1759 I=' ' 1760 else 1761 F="$1" 1762 NON_BLANK="$1" 1763 I='' 1764 while [ -n "${NON_BLANK}" ]; do 1765 I=" ${I}" 1766 NON_BLANK="${NON_BLANK#?}" 1767 done 1768 shift 1769 fi 1770 1771 if [ $# -eq 0 ]; then 1772 set -- list show copy delete edit find generate git gitconfig \ 1773 grep help init insert move random reencrypt version 1774 fi 1775 1776 NON_BLANK="${PROGRAM}" 1777 BLANKPG='' 1778 while [ -n "${NON_BLANK}" ]; do 1779 BLANKPG=" ${BLANKPG}" 1780 NON_BLANK="${NON_BLANK#?}" 1781 done 1782 unset NON_BLANK 1783 1784 for ARG in "$@"; do 1785 case "${ARG}" in 1786 list) 1787 cat <<EOF 1788 ${F}${PROGRAM} [list] [--raw,-r] [subfolder] 1789 EOF 1790 [ "${VERBOSE}" = yes ] && cat <<EOF 1791 ${I} List passwords as a tree or as a raw list. 1792 EOF 1793 ;; 1794 show) 1795 cat <<EOF 1796 ${F}${PROGRAM} [show] [--clip[=line-number],-c[line-number] | 1797 ${I}${BLANKPG} --qrcode[=line-number],-q[line-number]] pass-name 1798 EOF 1799 [ "${VERBOSE}" = yes ] && cat <<EOF 1800 ${I} Show existing password and optionally put it on the clipboard 1801 ${I} or display it as a QR-code. 1802 ${I} If put on the clipboard, it will be cleared in ${CLIP_TIME:-45} seconds. 1803 EOF 1804 ;; 1805 copy) 1806 cat <<EOF 1807 ${F}${PROGRAM} copy [--reencrypt,-e | --interactive,-i | --keep,-k ] 1808 ${I}${BLANKPG} [--force,-f] old-path new-path 1809 EOF 1810 [ "${VERBOSE}" = yes ] && cat <<EOF 1811 ${I} Copies old-path to new-path, optionally forcefully, 1812 ${I} reencrypting if needed or forced. 1813 EOF 1814 ;; 1815 delete) 1816 cat <<EOF 1817 ${F}${PROGRAM} delete [--recursive,-r] [--force,-f] pass-name 1818 EOF 1819 [ "${VERBOSE}" = yes ] && cat <<EOF 1820 ${I} Remove existing passwords or directories, optionally forcefully. 1821 EOF 1822 ;; 1823 edit) 1824 cat <<EOF 1825 ${F}${PROGRAM} edit pass-name 1826 EOF 1827 [ "${VERBOSE}" = yes ] && cat <<EOF 1828 ${I} Insert a new password or edit an existing password using an editor. 1829 EOF 1830 ;; 1831 find) 1832 cat <<EOF 1833 ${F}${PROGRAM} find [--raw,-r] [GREP_OPTIONS] regex 1834 EOF 1835 [ "${VERBOSE}" = yes ] && cat <<EOF 1836 ${I} List passwords that match the given regex. 1837 EOF 1838 ;; 1839 generate) 1840 cat <<EOF 1841 ${F}${PROGRAM} generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] 1842 ${I}${BLANKPG} [--in-place,-i | --force,-f] [--multiline,-m] 1843 ${I}${BLANKPG} [--try,-t] pass-name [pass-length [character-set]] 1844 EOF 1845 [ "${VERBOSE}" = yes ] && cat <<EOF 1846 ${I} Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified) 1847 ${I} with optionally no symbols. 1848 ${I} Optionally put it on the clipboard and clear board after ${CLIP_TIME:-45} seconds 1849 ${I} or display it as a QR-code. 1850 ${I} Prompt before overwriting existing password unless forced. 1851 ${I} Optionally replace only the first line of an existing file 1852 ${I} with a new password. 1853 ${I} Optionally prompt for confirmation between generation and saving. 1854 EOF 1855 ;; 1856 git) 1857 cat <<EOF 1858 ${F}${PROGRAM} git git-command-args ... 1859 EOF 1860 [ "${VERBOSE}" = yes ] && cat <<EOF 1861 ${I} If the password store is a git repository, execute a git command 1862 ${I} specified by git-command-args. 1863 EOF 1864 ;; 1865 gitconfig) 1866 cat <<EOF 1867 ${F}${PROGRAM} gitconfig 1868 EOF 1869 [ "${VERBOSE}" = yes ] && cat <<EOF 1870 ${I} If the password store is a git repository, enforce local configuration. 1871 EOF 1872 ;; 1873 grep) 1874 cat <<EOF 1875 ${F}${PROGRAM} grep [GREP_OPTIONS] search-regex 1876 EOF 1877 [ "${VERBOSE}" = yes ] && cat <<EOF 1878 ${I} Search for password files matching search-regex when decrypted. 1879 EOF 1880 ;; 1881 help) 1882 cat <<EOF 1883 ${F}${PROGRAM} help 1884 EOF 1885 [ "${VERBOSE}" = yes ] && cat <<EOF 1886 ${I} Show this text. 1887 EOF 1888 ;; 1889 init) 1890 cat <<EOF 1891 ${F}${PROGRAM} init [--interactive,-i | --keep,-k ] 1892 ${I}${BLANKPG} [--path=subfolder,-p subfolder] age-recipient ... 1893 EOF 1894 [ "${VERBOSE}" = yes ] && cat <<EOF 1895 ${I} Initialize new password storage and use the given age recipients 1896 ${I} for encryption. 1897 ${I} Selectively reencrypt existing passwords using new recipients. 1898 EOF 1899 ;; 1900 insert) 1901 cat <<EOF 1902 ${F}${PROGRAM} insert [--echo,-e | --multiline,-m] [--force,-f] pass-name 1903 EOF 1904 [ "${VERBOSE}" = yes ] && cat <<EOF 1905 ${I} Insert new password. Optionally, echo the password back to the console 1906 ${I} during entry. Or, optionally, the entry may be multiline. 1907 ${I} Prompt before overwriting existing password unless forced. 1908 EOF 1909 ;; 1910 move) 1911 cat <<EOF 1912 ${F}${PROGRAM} move [--reencrypt,-e | --interactive,-i | --keep,-k ] 1913 ${I}${BLANKPG} [--force,-f] old-path new-path 1914 EOF 1915 [ "${VERBOSE}" = yes ] && cat <<EOF 1916 ${I} Renames or moves old-path to new-path, optionally forcefully, 1917 ${I} reencrypting if needed or forced. 1918 EOF 1919 ;; 1920 random) 1921 cat <<EOF 1922 ${F}${PROGRAM} random [pass-length [character-set]] 1923 EOF 1924 [ "${VERBOSE}" = yes ] && cat <<EOF 1925 ${I} Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified) 1926 ${I} using the given character set (or ${CHARACTER_SET} if unspecified) 1927 ${I} without recording it in the password store. 1928 EOF 1929 ;; 1930 reencrypt) 1931 cat <<EOF 1932 ${F}${PROGRAM} reencrypt [--deep,-d] [--interactive,-i] pass-name|subfolder ... 1933 EOF 1934 [ "${VERBOSE}" = yes ] && cat <<EOF 1935 ${I} Re-encrypt in-place a secret or all the secrets in a subfolder, 1936 ${I} optionally including subfolders with their own recipients, 1937 ${I} and optionally asking before each one. 1938 EOF 1939 ;; 1940 version) 1941 cat <<EOF 1942 ${F}${PROGRAM} version 1943 EOF 1944 [ "${VERBOSE}" = yes ] && cat <<EOF 1945 ${I} Show version information. 1946 EOF 1947 ;; 1948 *) 1949 die "cmd_usage: unknown command \"${ARG}\"" 1950 ;; 1951 esac 1952 1953 F="${I}" 1954 done 1955 1956 unset VERBOSE 1957 } 1958 1959 cmd_version() { 1960 cat <<-EOF 1961 ============================================== 1962 = pashage: age-backed POSIX password manager = 1963 = = 1964 = v1.0.0 = 1965 = = 1966 = Natasha Kerensikova = 1967 = = 1968 = Based on: = 1969 = password-store by Jason A. Donenfeld = 1970 = passage by Filippo Valsorda = 1971 = pash by Dylan Araps = 1972 ============================================== 1973 EOF 1974 }