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