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