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