1#!/usr/bin/env bash 2 3# Folders: 4_SECRETS_DIR=${SECRETS_DIR:-".gitsecret"} 5# if SECRETS_DIR env var is set, use that instead of .gitsecret 6# for full path to secrets dir, use _get_secrets_dir() from _git_secret_tools.sh 7_SECRETS_DIR_KEYS="${_SECRETS_DIR}/keys" 8_SECRETS_DIR_PATHS="${_SECRETS_DIR}/paths" 9 10# Files: 11_SECRETS_DIR_KEYS_MAPPING="${_SECRETS_DIR_KEYS}/mapping.cfg" 12_SECRETS_DIR_KEYS_TRUSTDB="${_SECRETS_DIR_KEYS}/trustdb.gpg" 13 14_SECRETS_DIR_PATHS_MAPPING="${_SECRETS_DIR_PATHS}/mapping.cfg" 15 16: "${SECRETS_EXTENSION:=".secret"}" 17 18# Commands: 19: "${SECRETS_GPG_COMMAND:="gpg"}" 20: "${SECRETS_CHECKSUM_COMMAND:="_os_based __sha256"}" 21: "${SECRETS_OCTAL_PERMS_COMMAND:="_os_based __get_octal_perms"}" 22: "${SECRETS_EPOCH_TO_DATE:="_os_based __epoch_to_date"}" 23 24 25# AWK scripts: 26# shellcheck disable=2016 27AWK_FSDB_HAS_RECORD=' 28BEGIN { FS=":"; OFS=":"; cnt=0; } 29{ 30 if ( key == $1 ) 31 { 32 cnt++ 33 } 34} 35END { if ( cnt > 0 ) print "0"; else print "1"; } 36' 37 38# shellcheck disable=2016 39AWK_FSDB_RM_RECORD=' 40BEGIN { FS=":"; OFS=":"; } 41{ 42 if ( key != $1 ) 43 { 44 print $1,$2; 45 } 46} 47' 48 49# shellcheck disable=2016 50AWK_FSDB_CLEAR_HASHES=' 51BEGIN { FS=":"; OFS=":"; } 52{ 53 print $1,""; 54} 55' 56 57# shellcheck disable=2016 58AWK_GPG_VER_CHECK=' 59/^gpg/{ 60 version=$3 61 n=split(version,array,".") 62 if( n >= 2) { 63 if(array[1] >= 2) 64 { 65 if(array[2] >= 1) 66 { 67 print 1 68 } 69 else 70 { 71 print 0 72 } 73 } 74 else 75 { 76 print 0 77 } 78 } 79 else if(array[1] >= 2) 80 { 81 print 1 82 } 83 else 84 { 85 print 0 86 } 87} 88' 89 90# This is 1 for gpg version 2.1 or greater, otherwise 0 91GPG_VER_21="$(gpg --version | gawk "$AWK_GPG_VER_CHECK")" 92 93 94# Bash: 95 96function _function_exists { 97 local function_name="$1" # required 98 99 declare -f -F "$function_name" > /dev/null 2>&1 100 echo $? 101} 102 103 104# OS based: 105 106function _os_based { 107 # Pass function name as first parameter. 108 # It will be invoked as os-based function with the postfix. 109 110 case "$(uname -s)" in 111 112 Darwin) 113 "$1_osx" "${@:2}" 114 ;; 115 116 Linux) 117 "$1_linux" "${@:2}" 118 ;; 119 120 MINGW*) 121 "$1_linux" "${@:2}" 122 ;; 123 124 FreeBSD) 125 "$1_freebsd" "${@:2}" 126 ;; 127 128 # TODO: add MS Windows support. 129 # CYGWIN*|MINGW32*|MSYS*) 130 # $1_ms ${@:2} 131 # ;; 132 133 *) 134 _abort 'unsupported OS.' 135 ;; 136 esac 137} 138 139 140# File System: 141 142function _set_config { 143 # This function creates a line in the config, or alters it. 144 145 local key="$1" # required 146 local value="$2" # required 147 local filename="$3" # required 148 149 # The exit status is 0 (true) if the name was found, 1 (false) if not: 150 local contains 151 contains=$(grep -Fq "$key" "$filename"; echo "$?") 152 153 # Append or alter? 154 if [[ "$contains" -eq 0 ]]; then 155 _os_based __replace_in_file "$@" 156 elif [[ "$contains" -eq 1 ]]; then 157 echo "${key} = ${value}" >> "$filename" 158 fi 159} 160 161 162function _file_has_line { 163 # First parameter is the key, second is the filename. 164 165 local key="$1" # required 166 local filename="$2" # required 167 168 local contains 169 contains=$(grep -qw "$key" "$filename"; echo $?) 170 171 # 0 on contains, 1 for error. 172 echo "$contains" 173} 174 175 176function _delete_line { 177 local escaped_path 178 # shellcheck disable=2001 179 escaped_path=$(echo "$1" | sed -e 's/[\/&]/\\&/g') # required 180 181 local line="$2" # required 182 183 sed -i.bak "/$escaped_path/d" "$line" 184} 185 186 187# this sets the global variable 'filename' 188# currently this function is only used by 'hide' 189function _temporary_file { 190 # This function creates temporary file 191 # which will be removed on system exit. 192 filename=$(_os_based __temp_file) # is not `local` on purpose. 193 194 trap 'echo "cleaning up..."; rm -f "$filename";' EXIT 195} 196 197 198function _unique_filename { 199 # First parameter is base-path, second is filename, 200 # third is optional extension. 201 local n=0 202 local base_path="$1" 203 local result="$2" 204 205 while true; do 206 if [[ ! -f "$base_path/$result" ]]; then 207 break 208 fi 209 210 n=$(( n + 1 )) 211 result="${2}-${n}" # calling to the original "$2" 212 done 213 echo "$result" 214} 215 216# Helper function 217 218 219function _gawk_inplace { 220 local parms="$*" 221 local dest_file 222 dest_file="$(echo "$parms" | gawk -v RS="'" -v FS="'" 'END{ gsub(/^\s+/,""); print $1 }')" 223 224 _temporary_file 225 226 bash -c "gawk ${parms}" > "$filename" 227 mv "$filename" "$dest_file" 228} 229 230 231# File System Database (fsdb): 232 233 234function _get_record_filename { 235 # Returns 1st field from passed record 236 local record="$1" 237 local filename 238 filename=$(echo "$record" | awk -F: '{print $1}') 239 240 echo "$filename" 241} 242 243 244function _get_record_hash { 245 # Returns 2nd field from passed record 246 local record="$1" 247 local hash 248 hash=$(echo "$record" | awk -F: '{print $2}') 249 250 echo "$hash" 251} 252 253 254function _fsdb_has_record { 255 # First parameter is the key 256 # Second is the fsdb 257 local key="$1" # required 258 local fsdb="$2" # required 259 260 # 0 on contains, 1 for error. 261 gawk -v key="$key" "$AWK_FSDB_HAS_RECORD" "$fsdb" 262} 263 264 265function _fsdb_rm_record { 266 # First parameter is the key (filename) 267 # Second is the path to fsdb 268 local key="$1" # required 269 local fsdb="$2" # required 270 271 _gawk_inplace -v key="'$key'" "'$AWK_FSDB_RM_RECORD'" "$fsdb" 272} 273 274function _fsdb_clear_hashes { 275 # First parameter is the path to fsdb 276 local fsdb="$1" # required 277 278 _gawk_inplace "'$AWK_FSDB_CLEAR_HASHES'" "$fsdb" 279} 280 281 282# Manuals: 283 284function _show_manual_for { 285 local function_name="$1" # required 286 287 man "git-secret-${function_name}" 288 exit 0 289} 290 291 292# Invalid options 293 294function _invalid_option_for { 295 local function_name="$1" # required 296 297 man "git-secret-${function_name}" 298 exit 1 299} 300 301 302# VCS: 303 304function _check_ignore { 305 local filename="$1" # required 306 307 local result 308 result="$(git add -n "$filename" > /dev/null 2>&1; echo $?)" 309 # when ignored 310 if [[ "$result" -ne 0 ]]; then 311 result=0 312 else 313 result=1 314 fi 315 # returns 1 when not ignored, and 0 when ignored 316 echo "$result" 317} 318 319 320function _git_normalize_filename { 321 local filename="$1" # required 322 323 local result 324 result=$(git ls-files --full-name -o "$filename") 325 echo "$result" 326} 327 328 329function _maybe_create_gitignore { 330 # This function creates '.gitignore' if it was missing. 331 332 local full_path 333 full_path=$(_append_root_path '.gitignore') 334 335 if [[ ! -f "$full_path" ]]; then 336 touch "$full_path" 337 fi 338} 339 340 341function _add_ignored_file { 342 # This function adds a line with the filename into the '.gitignore' file. 343 # It also creates '.gitignore' if it's not there 344 345 local filename="$1" # required 346 347 _maybe_create_gitignore 348 349 local full_path 350 full_path=$(_append_root_path '.gitignore') 351 352 echo "$filename" >> "$full_path" 353} 354 355 356function _is_inside_git_tree { 357 # Checks if we are working inside the `git` tree. 358 local result 359 result=$(git rev-parse --is-inside-work-tree > /dev/null 2>&1; echo $?) 360 361 echo "$result" 362} 363 364function _is_tracked_in_git { 365 local filename="$1" # required 366 local result 367 result="$(git ls-files --error-unmatch "$filename" >/dev/null 2>&1; echo $?)" 368 369 if [[ "$result" -eq 0 ]]; then 370 echo "1" 371 else 372 echo "0" 373 fi 374} 375 376 377function _get_git_root_path { 378 # We need this function to get the location of the `.git` folder, 379 # since `.gitsecret` (or value set by SECRETS_DIR env var) must be on the same level. 380 381 local result 382 result=$(git rev-parse --show-toplevel) 383 echo "$result" 384} 385 386 387# Relative paths: 388 389function _append_root_path { 390 # This function adds root path to any other path. 391 392 local path="$1" # required 393 394 local root_path 395 root_path=$(_get_git_root_path) 396 397 echo "$root_path/$path" 398} 399 400 401function _get_secrets_dir { 402 _append_root_path "${_SECRETS_DIR}" 403} 404 405 406function _get_secrets_dir_keys { 407 _append_root_path "${_SECRETS_DIR_KEYS}" 408} 409 410 411function _get_secrets_dir_path { 412 _append_root_path "${_SECRETS_DIR_PATHS}" 413} 414 415 416function _get_secrets_dir_keys_mapping { 417 _append_root_path "${_SECRETS_DIR_KEYS_MAPPING}" 418} 419 420 421function _get_secrets_dir_keys_trustdb { 422 _append_root_path "${_SECRETS_DIR_KEYS_TRUSTDB}" 423} 424 425 426function _get_secrets_dir_paths_mapping { 427 _append_root_path "${_SECRETS_DIR_PATHS_MAPPING}" 428} 429 430 431# Logic: 432 433function _abort { 434 local message="$1" # required 435 local exit_code=${2:-"1"} # defaults to 1 436 437 >&2 echo "git-secret: abort: $message" 438 exit "$exit_code" 439} 440 441# _warn() sends warnings to stdout so user sees them 442function _warn { 443 local message="$1" # required 444 445 >&2 echo "git-secret: warning: $message" 446} 447 448# _warn_or_abort "$error_message" "$exit_code" "$error_ok" 449function _warn_or_abort { 450 local message="$1" # required 451 local exit_code=${2:-"1"} # defaults to 1 452 local error_ok=${3:-0} # can be 0 or 1 453 454 if [[ "$error_ok" -eq "0" ]]; then 455 if [[ "$exit_code" -eq "0" ]]; then 456 # if caller sends an exit_code of 0, we change it to 1 before aborting. 457 exit_code=1 458 fi 459 _abort "$message" "$exit_code" 460 else 461 _warn "$message" "$exit_code" 462 fi 463} 464 465function _find_and_clean { 466 # required: 467 local pattern="$1" # can be any string pattern 468 469 # optional: 470 local verbose=${2:-""} # can be empty or should be equal to "v" 471 472 local root 473 root=$(_get_git_root_path) 474 475 # shellcheck disable=2086 476 find "$root" -path "$pattern" -type f -print0 | xargs -0 rm -f$verbose 477} 478 479 480function _find_and_clean_formatted { 481 # required: 482 local pattern="$1" # can be any string pattern 483 484 # optional: 485 local verbose=${2:-""} # can be empty or should be equal to "v" 486 local message=${3:-"cleaning:"} # can be any string 487 488 if [[ -n "$verbose" ]]; then 489 echo && echo "$message" 490 fi 491 492 _find_and_clean "$pattern" "$verbose" 493 494 if [[ -n "$verbose" ]]; then 495 echo 496 fi 497} 498 499 500# this sets the global array variable 'filenames' 501function _list_all_added_files { 502 local path_mappings 503 path_mappings=$(_get_secrets_dir_paths_mapping) 504 505 if [[ ! -s "$path_mappings" ]]; then 506 _abort "$path_mappings is missing." 507 fi 508 509 local filename 510 filenames=() # not local 511 while read -r line; do 512 filename=$(_get_record_filename "$line") 513 filenames+=("$filename") 514 done < "$path_mappings" 515 516 declare -a filenames # so caller can get list from filenames array 517} 518 519 520function _secrets_dir_exists { 521 # This function checks if "$_SECRETS_DIR" exists and. 522 523 local full_path 524 full_path=$(_get_secrets_dir) 525 526 if [[ ! -d "$full_path" ]]; then 527 local name 528 name=$(basename "$full_path") 529 _abort "directory '$name' does not exist. Use 'git secret init' to initialize git-secret" 530 fi 531} 532 533 534function _secrets_dir_is_not_ignored { 535 # This function checks that "$_SECRETS_DIR" is not ignored. 536 537 local git_secret_dir 538 git_secret_dir=$(_get_secrets_dir) 539 540 # Create git_secret_dir required for check 541 local cleanup=0 542 if [[ ! -d "$git_secret_dir" ]]; then 543 mkdir "$git_secret_dir" 544 cleanup=1 545 fi 546 local ignores 547 ignores=$(_check_ignore "$git_secret_dir") 548 if [[ "$cleanup" == 1 ]]; then 549 rmdir "$git_secret_dir" 550 fi 551 552 if [[ ! $ignores -eq 1 ]]; then 553 _abort "'$git_secret_dir' is in .gitignore" 554 fi 555} 556 557 558function _user_required { 559 # This function does a bunch of validations: 560 # 1. It calls `_secrets_dir_exists` to verify that "$_SECRETS_DIR" exists. 561 # 2. It ensures that "$_SECRETS_DIR_KEYS_TRUSTDB" exists. 562 # 3. It ensures that there are added public keys. 563 564 _secrets_dir_exists 565 566 local trustdb 567 trustdb=$(_get_secrets_dir_keys_trustdb) 568 569 local error_message="no public keys for users found. run 'git secret tell email@address'." 570 if [[ ! -f "$trustdb" ]]; then 571 _abort "$error_message" 572 fi 573 574 local secrets_dir_keys 575 secrets_dir_keys=$(_get_secrets_dir_keys) 576 577 local keys_exist 578 keys_exist=$($SECRETS_GPG_COMMAND --homedir "$secrets_dir_keys" --no-permission-warning -n --list-keys) 579 local exit_code=$? 580 if [[ "$exit_code" -ne 0 ]]; then 581 # this might catch corner case where gpg --list-keys shows 582 # 'gpg: skipped packet of type 12 in keybox' warnings but succeeds? 583 # See #136 584 _abort "problem listing public keys with gpg: exit code $exit_code" 585 fi 586 if [[ -z "$keys_exist" ]]; then 587 _abort "$error_message" 588 fi 589} 590 591# note: this has the same 'username matching' issue described in 592# https://github.com/sobolevn/git-secret/issues/268 593# where it will match emails that have other emails as substrings. 594# we need to use fingerprints for a unique key id with gpg. 595function _get_user_key_expiry { 596 # This function returns the user's key's expiry, as an epoch. 597 # It will return the empty string if there is no expiry date for the user's key 598 local username="$1" 599 local line 600 601 local secrets_dir_keys 602 secrets_dir_keys=$(_get_secrets_dir_keys) 603 604 line=$($SECRETS_GPG_COMMAND --homedir "$secrets_dir_keys" --no-permission-warning --list-public-keys --with-colon --fixed-list-mode "$username" | grep ^pub:) 605 606 local expiry_epoch 607 expiry_epoch=$(echo "$line" | cut -d: -f7) 608 echo "$expiry_epoch" 609} 610 611 612function _assert_keychain_contains_emails { 613 local homedir=$1 614 local emails=$2 615 616 local gpg_uids 617 gpg_uids=$(_get_users_in_gpg_keyring "$homedir") 618 for email in "${emails[@]}"; do 619 local email_ok=0 620 for uid in $gpg_uids; do 621 if [[ "$uid" == "$email" ]]; then 622 email_ok=1 623 fi 624 done 625 if [[ $email_ok -eq 0 ]]; then 626 _abort "email not found in gpg keyring: $email" 627 fi 628 done 629} 630 631 632function _get_raw_filename { 633 echo "$(dirname "$1")/$(basename "$1" "$SECRETS_EXTENSION")" | sed -e 's#^\./##' 634} 635 636 637function _get_encrypted_filename { 638 local filename 639 filename="$(dirname "$1")/$(basename "$1" "$SECRETS_EXTENSION")" 640 echo "${filename}${SECRETS_EXTENSION}" | sed -e 's#^\./##' 641} 642 643 644function _get_users_in_gpg_keyring { 645 # show the users in the gpg keyring. 646 # `whoknows` command uses it internally. 647 # parses the `gpg` public keys 648 local homedir=$1 649 local result 650 local args=() 651 if [[ -n "$homedir" ]]; then 652 args+=( "--homedir" "$homedir" ) 653 fi 654 655 # pluck out 'uid' lines, fetch 10th field, extract part in <> if it exists (else leave alone). 656 # we use --fixed-list-mode so older versions of gpg emit 'uid:' lines. 657 # sed at the end is to extract email from <>. (If there's no <>, then the line is just an email address anyway.) 658 result=$($SECRETS_GPG_COMMAND "${args[@]}" --no-permission-warning --list-public-keys --with-colon --fixed-list-mode | grep ^uid: | cut -d: -f10 | sed 's/.*<\(.*\)>.*/\1/') 659 660 echo "$result" 661} 662 663 664function _get_users_in_gitsecret_keyring { 665 # show the users in the gitsecret keyring. 666 local secrets_dir_keys 667 secrets_dir_keys=$(_get_secrets_dir_keys) 668 669 local result 670 result=$(_get_users_in_gpg_keyring "$secrets_dir_keys") 671 672 echo "$result" 673} 674 675 676function _get_recipients { 677 # This function is required to create an encrypted file for different users. 678 # These users are called 'recipients' in the `gpg` terms. 679 # It basically just parses the `gpg` public keys 680 681 local result 682 result=$(_get_users_in_gitsecret_keyring | sed 's/^/-r/') # put -r before each user 683 echo "$result" 684} 685 686 687function _decrypt { 688 # required: 689 local filename="$1" 690 691 # optional: 692 local write_to_file=${2:-1} # can be 0 or 1 693 local force=${3:-0} # can be 0 or 1 694 local homedir=${4:-""} 695 local passphrase=${5:-""} 696 local error_ok=${6:-0} # can be 0 or 1 697 698 local encrypted_filename 699 encrypted_filename=$(_get_encrypted_filename "$filename") 700 701 local args=( "--use-agent" "--decrypt" "--no-permission-warning" ) 702 703 if [[ "$write_to_file" -eq 1 ]]; then 704 args+=( "-o" "$filename" ) 705 fi 706 707 if [[ "$force" -eq 1 ]]; then 708 args+=( "--yes" ) 709 fi 710 711 if [[ -n "$homedir" ]]; then 712 args+=( "--homedir" "$homedir" ) 713 fi 714 715 if [[ "$GPG_VER_21" -eq 1 ]]; then 716 args+=( "--pinentry-mode" "loopback" ) 717 fi 718 719 set +e # disable 'set -e' so we can capture exit_code 720 721 #echo "# gpg passphrase: $passphrase" >&3 722 local exit_code 723 if [[ -n "$passphrase" ]]; then 724 echo "$passphrase" | $SECRETS_GPG_COMMAND "${args[@]}" --quiet --batch --yes --no-tty --passphrase-fd 0 \ 725 "$encrypted_filename" 726 exit_code=$? 727 else 728 $SECRETS_GPG_COMMAND "${args[@]}" "--quiet" "$encrypted_filename" 729 exit_code=$? 730 fi 731 732 set -e # re-enable set -e 733 734 # note that according to https://github.com/sobolevn/git-secret/issues/238 , 735 # it's possible for gpg to return a 0 exit code but not have decrypted the file 736 #echo "# gpg exit code: $exit_code, error_ok: $error_ok" >&3 737 if [[ "$exit_code" -ne "0" ]]; then 738 local msg="problem decrypting file with gpg: exit code $exit_code: $filename" 739 _warn_or_abort "$msg" "$exit_code" "$error_ok" 740 fi 741 742 # at this point the file should be written to disk or output to stdout 743} 744 745