pashage.sh (41708B)
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 add 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 # BEGIN_GPG_NAME: marker before gpg secret name 854 # END_GPG_NAME: marker after gpg secret name 855 # HAS_ITEMS: (output) set to `yes` when something has been printed 856 # LIST_EMPTY: include empty directories in output when set to `yes` 857 # Note that this function is recrusive and cannot use variables to hold state 858 # (except for HAS_ITEMS which is carefully designed with this constraint). 859 do_list() { 860 for FULL_ENTRY in "${PREFIX}/$1${1:+/}"*; do 861 ENTRY="${FULL_ENTRY#"${PREFIX}/"}" 862 ITEM_NAME="${ENTRY##*/}" 863 if [ -d "${FULL_ENTRY}" ]; then 864 shift 865 set -- "${ENTRY}" "$@" 866 HAS_ITEMS=no 867 do_list "$@" 868 if [ "${LIST_EMPTY}${HAS_ITEMS}" = 'yesno' ]; then 869 printf '%s/\n' "$1" 870 fi 871 HAS_ITEMS=yes 872 elif [ "${ENTRY%.age}.age" = "${ENTRY}" ]; then 873 shift 874 set -- "-q" "$@" 875 if [ $# -le 1 ] \ 876 || printf '%s\n' "${ITEM_NAME%.age}" | grep "$@" 877 then 878 printf '%s\n' "${ENTRY%.age}" 879 HAS_ITEMS=yes 880 fi 881 elif [ "${ENTRY%.gpg}.gpg" = "${ENTRY}" ]; then 882 shift 883 set -- "-q" "$@" 884 if [ $# -le 1 ] \ 885 || printf '%s\n' "${ITEM_NAME%.gpg}" | grep "$@" 886 then 887 if ! [ "${ENTRY}" = "${ITEM_NAME}" ]; then 888 printf '%s' "${ENTRY%/*}/" 889 fi 890 if ! [ -d "${FULL_ENTRY%.gpg}" ] \ 891 && ! [ -f "${FULL_ENTRY%.gpg}.age" ] 892 then 893 ITEM_NAME="${ITEM_NAME%.gpg}" 894 fi 895 printf '%s%s%s\n' \ 896 "${BEGIN_GPG_NAME}" \ 897 "${ITEM_NAME}" \ 898 "${END_GPG_NAME}" 899 HAS_ITEMS=yes 900 fi 901 fi 902 unset ENTRY 903 unset ITEM_NAME 904 done 905 unset FULL_ENTRY 906 } 907 908 # Display a single directory or entry 909 # $1: entry name 910 # LIST_VIEW: whether directories are displayed as a list rather then a tree 911 do_list_or_show() { 912 if [ -z "$1" ]; then 913 if [ "${LIST_VIEW-no}" = "yes" ]; then 914 BEGIN_GPG_NAME='' 915 END_GPG_NAME='' 916 LIST_EMPTY='no' 917 do_list '' 918 unset BEGIN_GPG_NAME 919 unset END_GPG_NAME 920 unset LIST_EMPTY 921 else 922 printf 'Password Store\n' 923 do_tree '' 924 fi 925 elif [ -f "${PREFIX}/$1.age" ]; then 926 SECRET="$(do_decrypt "${PREFIX}/$1.age")" 927 do_show "$1" <<-EOF 928 ${SECRET} 929 EOF 930 unset SECRET 931 elif [ -d "${PREFIX}/$1" ]; then 932 if [ "${LIST_VIEW-no}" = "yes" ]; then 933 BEGIN_GPG_NAME='' 934 END_GPG_NAME='' 935 LIST_EMPTY='no' 936 do_list "${1%/}" 937 unset BEGIN_GPG_NAME 938 unset END_GPG_NAME 939 unset LIST_EMPTY 940 else 941 printf '%s\n' "${1%/}" 942 do_tree "${1%/}" 943 fi 944 elif [ -f "${PREFIX}/$1.gpg" ]; then 945 SECRET="$(do_decrypt_gpg "${PREFIX}/$1.gpg")" 946 do_show "$1" <<-EOF 947 ${SECRET} 948 EOF 949 unset SECRET 950 else 951 die "Error: $1 is not in the password store." 952 fi 953 } 954 955 # Re-encrypts a file or a directory 956 # $1: entry name 957 # DECISION: whether to ask before re-encryption 958 do_reencrypt() { 959 scm_begin 960 961 if [ "$1" = "${1%/}/" ]; then 962 if ! [ -d "${PREFIX}/${1%/}" ]; then 963 die "Error: $1 is not in the password store." 964 fi 965 do_reencrypt_dir "${PREFIX}/${1%/}" 966 LOC="$1" 967 968 elif [ -f "${PREFIX}/$1.age" ]; then 969 do_reencrypt_file "$1" 970 LOC="$1" 971 972 elif [ -d "${PREFIX}/$1" ]; then 973 do_reencrypt_dir "${PREFIX}/$1" 974 LOC="$1/" 975 976 else 977 die "Error: $1 is not in the password store." 978 fi 979 980 scm_commit "Re-encrypt ${LOC}" 981 unset LOC 982 } 983 984 # Recursively re-encrypts a directory 985 # $1: absolute directory path 986 # DECISION: whether to ask before re-encryption 987 do_reencrypt_dir() { 988 for ENTRY in "${1%/}"/*; do 989 if [ -d "${ENTRY}" ]; then 990 if ! [ -e "${ENTRY}/.age-recipients" ] \ 991 || [ "${DECISION}" = force ] 992 then 993 ( do_reencrypt_dir "${ENTRY}" ) 994 fi 995 elif [ "${ENTRY}" = "${ENTRY%.age}.age" ]; then 996 ENTRY="${ENTRY#"${PREFIX}"/}" 997 do_reencrypt_file "${ENTRY%.age}" 998 fi 999 done 1000 } 1001 1002 # Re-encrypts a file 1003 # $1: entry name 1004 # DECISION: whether to ask before re-encryption 1005 do_reencrypt_file() { 1006 if [ "${DECISION}" = interactive ]; then 1007 yesno "Re-encrypt $1?" 1008 [ "${ANSWER}" = y ] || return 0 1009 unset ANSWER 1010 fi 1011 1012 OVERWRITE=once 1013 WIP_FILE="$(mktemp -u "${PREFIX}/$1-XXXXXXXXX.age")" 1014 SECRET="$(do_decrypt "${PREFIX}/$1.age")" 1015 do_encrypt "${WIP_FILE#"${PREFIX}"/}" <<-EOF 1016 ${SECRET} 1017 EOF 1018 mv -f -- "${WIP_FILE}" "${PREFIX}/$1.age" 1019 unset WIP_FILE 1020 scm_add "$1.age" 1021 } 1022 1023 # Display a decrypted secret from standard input 1024 # $1: title 1025 # SELECTED_LINE: which line to paste or diplay as qr-code 1026 # SHOW: how to show the secret 1027 do_show() { 1028 unset SECRET 1029 1030 case "${SHOW}" in 1031 text) 1032 cat 1033 ;; 1034 clip) 1035 tail -n "+${SELECTED_LINE}" \ 1036 | head -n 1 \ 1037 | tr -d '\n' \ 1038 | platform_clip "$1" 1039 ;; 1040 qrcode) 1041 tail -n "+${SELECTED_LINE}" \ 1042 | head -n 1 \ 1043 | tr -d '\n' \ 1044 | platform_qrcode "$1" 1045 ;; 1046 *) 1047 die "Unexpected SHOW value \"${SHOW}\"" 1048 ;; 1049 esac 1050 } 1051 1052 # Display a list of secret as a tree 1053 # $1: path relative to prefix 1054 # ...: (optional) grep arguments to filter 1055 do_tree() { 1056 REVERSE="" 1057 BEGIN_GPG_NAME="${RED_TEXT}" 1058 END_GPG_NAME="${NORMAL_TEXT}" 1059 LIST_EMPTY="${2+no}" 1060 LIST_EMPTY="${LIST_EMPTY:-yes}" 1061 ENTRY_LIST="$(do_list "$@")" 1062 while read -r LINE; do 1063 REVERSE="${LINE#"${1-}${1:+/}"}${REVERSE:+"${NL}"}${REVERSE}" 1064 done <<-EOF 1065 ${ENTRY_LIST} 1066 EOF 1067 unset BEGIN_GPG_NAME 1068 unset END_GPG_NAME 1069 unset LIST_EMPTY 1070 unset ENTRY_LIST 1071 1072 GRAPH="_" 1073 TREE="" 1074 PREV_LINE="" 1075 while read -r LINE; do 1076 LINE_COPY="${LINE}" 1077 # Skip common directory prefix 1078 while ! [ "${LINE_COPY%%/*}" = "${LINE_COPY}" ] \ 1079 && ! [ "${PREV_LINE%%/*}" = "${PREV_LINE}" ] \ 1080 && [ "${LINE_COPY%%/*}" = "${PREV_LINE%%/*}" ] 1081 do 1082 LINE_COPY="${LINE_COPY#*/}" 1083 PREV_LINE="${PREV_LINE#*/}" 1084 done 1085 1086 # Output obsolete directories 1087 while ! [ "${PREV_LINE%/*}" = "${PREV_LINE}" ]; do 1088 PREV_LINE="${PREV_LINE%/*}" 1089 do_tree_prefix "${GRAPH%?}" 1090 GRAPH="${GRAPH%??}I" 1091 TREE="${PREV_LINE##*/}${NORMAL_TEXT}${NL}${TREE}" 1092 TREE="${CGRAPH}${BLUE_TEXT}${TREE}" 1093 unset CGRAPH 1094 done 1095 1096 [ -z "${LINE}" ] && continue 1097 1098 # Prepare new directories 1099 while ! [ "${LINE_COPY%/*}" = "${LINE_COPY}" ]; do 1100 LINE_COPY="${LINE_COPY%/*}" 1101 GRAPH="${GRAPH}_" 1102 done 1103 unset LINE_COPY 1104 1105 if [ -n "${LINE##*/}" ]; then 1106 do_tree_prefix "${GRAPH}" 1107 GRAPH="${GRAPH%?}I" 1108 TREE="${CGRAPH}${LINE##*/}${NL}${TREE}" 1109 unset CGRAPH 1110 fi 1111 1112 PREV_LINE="${LINE}" 1113 done <<-EOF 1114 ${REVERSE} 1115 1116 EOF 1117 # Note the extra blank line above to flush the first directories 1118 unset GRAPH 1119 unset LINE 1120 unset PREV_LINE 1121 1122 printf '%s' "${TREE}" 1123 unset TREE 1124 } 1125 1126 1127 # Convert a tree prefix into user-facing representation 1128 # $1: encoded tree prefix 1129 # CGRAPH: output user-facing representation 1130 do_tree_prefix() { 1131 CGRAPH="" 1132 while [ -n "${1#?}" ]; do 1133 case "${1%"${1#?}"}" in 1134 (_) 1135 CGRAPH="${CGRAPH}${TREE__}" 1136 ;; 1137 (I) 1138 CGRAPH="${CGRAPH}${TREE_I}" 1139 ;; 1140 (*) 1141 die "Invalid tree prefix: \"$1\"" 1142 ;; 1143 esac 1144 set -- "${1#?}" 1145 done 1146 1147 case "$1" in 1148 (_) 1149 CGRAPH="${CGRAPH}${TREE_L}" 1150 ;; 1151 (I) 1152 CGRAPH="${CGRAPH}${TREE_T}" 1153 ;; 1154 (*) 1155 die "Invalid tree prefix: \"$1\"" 1156 ;; 1157 esac 1158 } 1159 1160 1161 ############ 1162 # COMMANDS # 1163 ############ 1164 1165 cmd_copy() { 1166 ACTION=Copy 1167 SCM_ACTION=scm_cp 1168 cmd_copy_move "$@" 1169 } 1170 1171 cmd_copy_move() { 1172 DECISION=default 1173 OVERWRITE=no 1174 PARSE_ERROR=no 1175 1176 while [ $# -ge 1 ]; do 1177 case "$1" in 1178 -f|--force) 1179 OVERWRITE=yes 1180 shift ;; 1181 -e|--reencrypt) 1182 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1183 DECISION=force 1184 shift ;; 1185 -i|--interactive) 1186 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1187 DECISION=interactive 1188 shift ;; 1189 -k|--keep) 1190 [ "${DECISION}" = default ] || PARSE_ERROR=yes 1191 DECISION=keep 1192 shift ;; 1193 -[efik]?*) 1194 REST="${1#??}" 1195 FIRST="${1%"${REST}"}" 1196 shift 1197 set -- "${FIRST}" "-${REST}" "$@" 1198 unset FIRST 1199 unset REST 1200 ;; 1201 --) 1202 shift 1203 break ;; 1204 -*) 1205 PARSE_ERROR=yes 1206 break ;; 1207 *) 1208 break ;; 1209 esac 1210 done 1211 1212 if [ "${PARSE_ERROR}" = yes ] || [ $# -lt 2 ]; then 1213 if [ "${COMMAND}" = "c${COMMAND#c}" ]; then 1214 cmd_usage 'Usage: ' copy >&2 1215 exit 1 1216 elif [ "${COMMAND}" = "m${COMMAND#m}" ]; then 1217 cmd_usage 'Usage: ' move >&2 1218 exit 1 1219 else 1220 cmd_usage 'Usage: ' copy move >&2 1221 exit 1 1222 fi 1223 fi 1224 unset PARSE_ERROR 1225 1226 check_sneaky_paths "$@" 1227 1228 if [ $# -gt 2 ]; then 1229 SHARED_DEST="$1" 1230 shift 1231 for ARG in "$@"; do 1232 shift 1233 set -- "$@" "${SHARED_DEST}" 1234 SHARED_DEST="${ARG}" 1235 done 1236 1237 for ARG in "$@"; do 1238 do_copy_move "${ARG}" "${SHARED_DEST%/}/" 1239 done 1240 else 1241 do_copy_move "$@" 1242 fi 1243 } 1244 1245 cmd_delete() { 1246 DECISION=default 1247 PARSE_ERROR=no 1248 RECURSIVE=no 1249 1250 while [ $# -ge 1 ]; do 1251 case "$1" in 1252 -f|--force) 1253 DECISION=force 1254 shift ;; 1255 -r|--recursive) 1256 RECURSIVE=yes 1257 shift ;; 1258 -[fr]?*) 1259 REST="${1#??}" 1260 FIRST="${1%"${REST}"}" 1261 shift 1262 set -- "${FIRST}" "-${REST}" "$@" 1263 unset FIRST 1264 unset REST 1265 ;; 1266 --) 1267 shift 1268 break ;; 1269 -*) 1270 PARSE_ERROR=yes 1271 break ;; 1272 *) 1273 break ;; 1274 esac 1275 done 1276 1277 if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then 1278 cmd_usage 'Usage: ' delete >&2 1279 exit 1 1280 fi 1281 unset PARSE_ERROR 1282 1283 check_sneaky_paths "$@" 1284 1285 for ARG in "$@"; do 1286 do_delete "${ARG}" 1287 done 1288 } 1289 1290 cmd_edit() { 1291 if [ $# -eq 0 ]; then 1292 cmd_usage 'Usage: ' edit >&2 1293 exit 1 1294 fi 1295 1296 check_sneaky_paths "$@" 1297 platform_tmpdir 1298 1299 for ARG in "$@"; do 1300 do_edit "${ARG}" 1301 done 1302 } 1303 1304 cmd_find() { 1305 LIST_VIEW=no 1306 case "${1-}" in 1307 -r|--raw) 1308 LIST_VIEW=yes 1309 shift ;; 1310 *) 1311 ;; 1312 esac 1313 1314 if [ $# -eq 0 ]; then 1315 cmd_usage 'Usage: ' find >&2 1316 exit 1 1317 fi 1318 1319 if [ "${LIST_VIEW}" = yes ]; then 1320 BEGIN_GPG_NAME='' 1321 END_GPG_NAME='' 1322 LIST_EMPTY='no' 1323 do_list '' "$@" 1324 unset BEGIN_GPG_NAME 1325 unset END_GPG_NAME 1326 unset LIST_EMPTY 1327 else 1328 printf 'Search pattern: %s\n' "$*" 1329 do_tree '' "$@" 1330 fi 1331 } 1332 1333 cmd_generate() { 1334 CHARSET="${CHARACTER_SET}" 1335 DECISION=default 1336 MULTILINE=no 1337 OVERWRITE=no 1338 PARSE_ERROR=no 1339 SELECTED_LINE=1 1340 SHOW=text 1341 1342 while [ $# -ge 1 ]; do 1343 case "$1" in 1344 -c|--clip) 1345 if ! [ "${SHOW}" = text ]; then 1346 PARSE_ERROR=yes 1347 break 1348 fi 1349 SHOW=clip 1350 shift ;; 1351 -f|--force) 1352 if ! [ "${OVERWRITE}" = no ]; then 1353 PARSE_ERROR=yes 1354 break 1355 fi 1356 OVERWRITE=yes 1357 shift ;; 1358 -i|--in-place) 1359 if ! [ "${OVERWRITE}" = no ]; then 1360 PARSE_ERROR=yes 1361 break 1362 fi 1363 OVERWRITE=reuse 1364 shift ;; 1365 -m|--multiline) 1366 MULTILINE=yes 1367 shift ;; 1368 -n|--no-symbols) 1369 CHARSET="${CHARACTER_SET_NO_SYMBOLS}" 1370 shift ;; 1371 -q|--qrcode) 1372 if ! [ "${SHOW}" = text ]; then 1373 PARSE_ERROR=yes 1374 break 1375 fi 1376 SHOW=qrcode 1377 shift ;; 1378 -t|--try) 1379 DECISION=interactive 1380 shift ;; 1381 -[cfimnqt]?*) 1382 REST="${1#-?}" 1383 ARG="${1%"${REST}"}" 1384 shift 1385 set -- "${ARG}" "-${REST}" "$@" 1386 unset ARG 1387 unset REST 1388 ;; 1389 --) 1390 shift 1391 break ;; 1392 -*) 1393 PARSE_ERROR=yes 1394 break ;; 1395 *) 1396 break ;; 1397 esac 1398 done 1399 1400 if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ] || [ $# -gt 3 ]; then 1401 cmd_usage 'Usage: ' generate >&2 1402 exit 1 1403 fi 1404 1405 unset PARSE_ERROR 1406 1407 check_sneaky_path "$1" 1408 LENGTH="${2:-${GENERATED_LENGTH}}" 1409 [ -n "${LENGTH##*[!0-9]*}" ] \ 1410 || die "Error: passlength \"${LENGTH}\" must be a number." 1411 [ "${LENGTH}" -gt 0 ] \ 1412 || die "Error: pass-length must be greater than zero." 1413 1414 do_generate "$1" "${LENGTH}" "${3:-${CHARSET}}" 1415 1416 unset CHARSET 1417 unset LENGTH 1418 } 1419 1420 cmd_git() { 1421 if [ -d "${PREFIX}/.git" ]; then 1422 platform_tmpdir 1423 TMPDIR="${SECURE_TMPDIR}" git -C "${PREFIX}" "$@" 1424 elif [ "${1-}" = init ]; then 1425 mkdir -p -- "${PREFIX}" 1426 git -C "${PREFIX}" "$@" 1427 scm_add '.' 1428 scm_commit "Add current contents of password store." 1429 cmd_gitconfig 1430 elif [ "${1-}" = clone ]; then 1431 git "$@" "${PREFIX}" 1432 cmd_gitconfig 1433 else 1434 die "Error: the password store is not a git repository." \ 1435 "Try \"${PROGRAM} git init\"." 1436 fi 1437 } 1438 1439 cmd_grep() { 1440 if [ $# -eq 0 ]; then 1441 cmd_usage 'Usage: ' grep >&2 1442 exit 1 1443 fi 1444 1445 ( cd "${PREFIX}" && do_grep "" "$@" ) 1446 } 1447 1448 cmd_gitconfig() { 1449 [ -d "${PREFIX}/.git" ] || die "The store is not a git repository." 1450 1451 if ! [ -f "${PREFIX}/.gitattributes" ] || 1452 ! grep -Fqx '*.age diff=age' "${PREFIX}/.gitattributes" 1453 then 1454 scm_begin 1455 printf '*.age diff=age\n' >>"${PREFIX}/.gitattributes" 1456 scm_add ".gitattributes" 1457 scm_commit "Configure git repository for age file diff." 1458 fi 1459 1460 git -C "${PREFIX}" config --local diff.age.binary true 1461 git -C "${PREFIX}" config --local diff.age.textconv \ 1462 "${AGE} -d -i ${IDENTITIES_FILE}" 1463 } 1464 1465 cmd_help() { 1466 cmd_version 1467 printf '\n' 1468 cmd_usage ' ' 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 '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 '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 'Usage: ' list >&2 1661 exit 1 1662 elif [ "${COMMAND}" = "s${COMMAND#s}" ]; then 1663 cmd_usage 'Usage: ' show >&2 1664 exit 1 1665 else 1666 cmd_usage '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 '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 1706 while [ $# -ge 1 ]; do 1707 case "$1" in 1708 -i|--interactive) 1709 DECISION=interactive 1710 shift ;; 1711 --) 1712 shift 1713 break ;; 1714 -*) 1715 PARSE_ERROR=yes 1716 break ;; 1717 *) 1718 break ;; 1719 esac 1720 done 1721 1722 if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then 1723 cmd_usage 'Usage: ' reencrypt >&2 1724 exit 1 1725 fi 1726 1727 unset PARSE_ERROR 1728 1729 check_sneaky_paths "$@" 1730 1731 for ARG in "$@"; do 1732 do_reencrypt "${ARG}" 1733 done 1734 unset ARG 1735 } 1736 1737 # Outputs the whole usage text 1738 # $1: indentation 1739 # ... commands to document 1740 cmd_usage(){ 1741 if [ $# -eq 0 ]; then 1742 F=' ' 1743 I=' ' 1744 else 1745 F="$1" 1746 NON_BLANK="$1" 1747 I='' 1748 while [ -n "${NON_BLANK}" ]; do 1749 I=" ${I}" 1750 NON_BLANK="${NON_BLANK#?}" 1751 done 1752 shift 1753 fi 1754 1755 if [ $# -eq 0 ]; then 1756 echo 'Usage:' 1757 set -- list show copy delete edit find generate git gitconfig \ 1758 grep help init insert move random reencrypt version 1759 VERBOSE=yes 1760 else 1761 VERBOSE=no 1762 fi 1763 1764 NON_BLANK="${PROGRAM}" 1765 BLANKPG='' 1766 while [ -n "${NON_BLANK}" ]; do 1767 BLANKPG=" ${BLANKPG}" 1768 NON_BLANK="${NON_BLANK#?}" 1769 done 1770 unset NON_BLANK 1771 1772 for ARG in "$@"; do 1773 case "${ARG}" in 1774 list) 1775 cat <<EOF 1776 ${F}${PROGRAM} [list] [--raw,-r] [subfolder] 1777 EOF 1778 [ "${VERBOSE}" = yes ] && cat <<EOF 1779 ${I} List passwords as a tree or as a raw list. 1780 EOF 1781 ;; 1782 show) 1783 cat <<EOF 1784 ${F}${PROGRAM} [show] [--clip[=line-number],-c[line-number] | 1785 ${I}${BLANKPG} --qrcode[=line-number],-q[line-number]] pass-name 1786 EOF 1787 [ "${VERBOSE}" = yes ] && cat <<EOF 1788 ${I} Show existing password and optionally put it on the clipboard 1789 ${I} or display it as a QR-code. 1790 ${I} If put on the clipboard, it will be cleared in ${CLIP_TIME:-45} seconds. 1791 EOF 1792 ;; 1793 copy) 1794 cat <<EOF 1795 ${F}${PROGRAM} copy [--reencrypt,-e | --interactive,-i | --keep,-k ] 1796 ${I}${BLANKPG} [--force,-f] old-path new-path 1797 EOF 1798 [ "${VERBOSE}" = yes ] && cat <<EOF 1799 ${I} Copies old-path to new-path, optionally forcefully, 1800 ${I} reencrypting if needed or forced. 1801 EOF 1802 ;; 1803 delete) 1804 cat <<EOF 1805 ${F}${PROGRAM} delete [--recursive,-r] [--force,-f] pass-name 1806 EOF 1807 [ "${VERBOSE}" = yes ] && cat <<EOF 1808 ${I} Remove existing passwords or directories, optionally forcefully. 1809 EOF 1810 ;; 1811 edit) 1812 cat <<EOF 1813 ${F}${PROGRAM} edit pass-name 1814 EOF 1815 [ "${VERBOSE}" = yes ] && cat <<EOF 1816 ${I} Insert a new password or edit an existing password using an editor. 1817 EOF 1818 ;; 1819 find) 1820 cat <<EOF 1821 ${F}${PROGRAM} find [--raw,-r] [GREP_OPTIONS] regex 1822 EOF 1823 [ "${VERBOSE}" = yes ] && cat <<EOF 1824 ${I} List passwords that match the given regex. 1825 EOF 1826 ;; 1827 generate) 1828 cat <<EOF 1829 ${F}${PROGRAM} generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] 1830 ${I}${BLANKPG} [--in-place,-i | --force,-f] [--multiline,-m] 1831 ${I}${BLANKPG} [--try,-t] pass-name [pass-length [character-set]] 1832 EOF 1833 [ "${VERBOSE}" = yes ] && cat <<EOF 1834 ${I} Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified) 1835 ${I} with optionally no symbols. 1836 ${I} Optionally put it on the clipboard and clear board after ${CLIP_TIME:-45} seconds 1837 ${I} or display it as a QR-code. 1838 ${I} Prompt before overwriting existing password unless forced. 1839 ${I} Optionally replace only the first line of an existing file 1840 ${I} with a new password. 1841 ${I} Optionally prompt for confirmation between generation and saving. 1842 EOF 1843 ;; 1844 git) 1845 cat <<EOF 1846 ${F}${PROGRAM} git git-command-args ... 1847 EOF 1848 [ "${VERBOSE}" = yes ] && cat <<EOF 1849 ${I} If the password store is a git repository, execute a git command 1850 ${I} specified by git-command-args. 1851 EOF 1852 ;; 1853 gitconfig) 1854 cat <<EOF 1855 ${F}${PROGRAM} gitconfig 1856 EOF 1857 [ "${VERBOSE}" = yes ] && cat <<EOF 1858 ${I} If the password store is a git repository, enforce local configuration. 1859 EOF 1860 ;; 1861 grep) 1862 cat <<EOF 1863 ${F}${PROGRAM} grep [GREP_OPTIONS] search-regex 1864 EOF 1865 [ "${VERBOSE}" = yes ] && cat <<EOF 1866 ${I} Search for password files matching search-regex when decrypted. 1867 EOF 1868 ;; 1869 help) 1870 cat <<EOF 1871 ${F}${PROGRAM} help 1872 EOF 1873 [ "${VERBOSE}" = yes ] && cat <<EOF 1874 ${I} Show this text. 1875 EOF 1876 ;; 1877 init) 1878 cat <<EOF 1879 ${F}${PROGRAM} init [--interactive,-i | --keep,-k ] 1880 ${I}${BLANKPG} [--path=subfolder,-p subfolder] age-recipient ... 1881 EOF 1882 [ "${VERBOSE}" = yes ] && cat <<EOF 1883 ${I} Initialize new password storage and use the given age recipients 1884 ${I} for encryption. 1885 ${I} Selectively reencrypt existing passwords using new recipients. 1886 EOF 1887 ;; 1888 insert) 1889 cat <<EOF 1890 ${F}${PROGRAM} insert [--echo,-e | --multiline,-m] [--force,-f] pass-name 1891 EOF 1892 [ "${VERBOSE}" = yes ] && cat <<EOF 1893 ${I} Insert new password. Optionally, echo the password back to the console 1894 ${I} during entry. Or, optionally, the entry may be multiline. 1895 ${I} Prompt before overwriting existing password unless forced. 1896 EOF 1897 ;; 1898 move) 1899 cat <<EOF 1900 ${F}${PROGRAM} move [--reencrypt,-e | --interactive,-i | --keep,-k ] 1901 ${I}${BLANKPG} [--force,-f] old-path new-path 1902 EOF 1903 [ "${VERBOSE}" = yes ] && cat <<EOF 1904 ${I} Renames or moves old-path to new-path, optionally forcefully, 1905 ${I} reencrypting if needed or forced. 1906 EOF 1907 ;; 1908 random) 1909 cat <<EOF 1910 ${F}${PROGRAM} random [pass-length [character-set]] 1911 EOF 1912 [ "${VERBOSE}" = yes ] && cat <<EOF 1913 ${I} Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified) 1914 ${I} using the given character set (or ${CHARACTER_SET} if unspecified) 1915 ${I} without recording it in the password store. 1916 EOF 1917 ;; 1918 reencrypt) 1919 cat <<EOF 1920 ${F}${PROGRAM} reencrypt [--interactive,-i] pass-name|subfolder ... 1921 EOF 1922 [ "${VERBOSE}" = yes ] && cat <<EOF 1923 ${I} Re-encrypt in-place a secret or all the secrets in a subfolder, 1924 ${I} optionally asking before each one. 1925 EOF 1926 ;; 1927 version) 1928 cat <<EOF 1929 ${F}${PROGRAM} version 1930 EOF 1931 [ "${VERBOSE}" = yes ] && cat <<EOF 1932 ${I} Show version information. 1933 EOF 1934 ;; 1935 *) 1936 die "cmd_usage: unknown command \"${ARG}\"" 1937 ;; 1938 esac 1939 1940 F="${I}" 1941 done 1942 } 1943 1944 cmd_version() { 1945 cat <<-EOF 1946 ============================================== 1947 = pashage: age-backed POSIX password manager = 1948 = = 1949 = v0.1.0 = 1950 = = 1951 = Natasha Kerensikova = 1952 = = 1953 = Based on: = 1954 = password-store by Jason A. Donenfeld = 1955 = passage by Filippo Valsorda = 1956 = pash by Dylan Araps = 1957 ============================================== 1958 EOF 1959 }