#!/bin/sh # git-remote-gcrypt # # git-remote-gcrypt is licensed under the terms of the GNU GPL version 3 # (or at your option, version 2, or any version later than GPLv3). # See http://www.gnu.org/licenses/ for more information. # # See README set -e set -u Localdir="${GIT_DIR:=.git}/remote-gcrypt" export GITCEPTION="${GITCEPTION:-}+" # Reuse $Gref except when stacked Gref="refs/gcrypt/gitception$GITCEPTION" Gref_rbranch="refs/heads/master" Packkey_bytes=33 # 33 random bytes for passphrase, still compatible if changed Hashtype=SHA256 # SHA512 SHA384 SHA256 SHA224 supported. Packpat="pack :*:" Manifestfile=91bd0c092128cf2e60e1a608c31e92caf1f9c1595f83f2890ef17c0e4881aa0a Hex40="[a-f0-9]" Hex40=$Hex40$Hex40$Hex40$Hex40$Hex40$Hex40$Hex40$Hex40 Hex40=$Hex40$Hex40$Hex40$Hex40$Hex40 # Match SHA-1 hexdigest Did_find_repo= # yes for connected, no for no repo Repoid= Refslist= Packlist= Keeplist= Extnlist= Repack_limit=25 Recipients= # compat/utility functions xecho() { cat <&2; } echo_die() { echo_info "$@" ; exit 1; } isnull() { case "$1" in "") return 0;; *) return 1;; esac; } isnonnull() { ! isnull "$1"; } iseq() { case "$1" in "$2") return 0;; *) return 1;; esac; } isnoteq() { ! iseq "$1" "$2"; } negate() { ! "$@"; } isurl() { isnull "${2%%$1://*}"; } islocalrepo() { isnull "${1##/*}" && [ ! -e "$1/HEAD" ]; } xgrep() { command grep "$@" || : ; } # setvar is used for named return variables # $1 *must* be a valid variable name, $2 is any value # # Conventions # return variable names are passed with a @ prefix # return variable functions use f_ prefix local vars # return var consumers use r_ prefix vars (or Titlecase globals) setvar() { isnull "${1##@*}" || echo_die "Missing @ for return variable: $1" eval ${1#@}=\$2 } Newline=" " # $1 is return var, $2 is value appended with newline separator append_to() { local f_append_tmp_= eval f_append_tmp_=\$${1#@} isnull "$f_append_tmp_" || f_append_tmp_=$f_append_tmp_$Newline setvar "$1" "$f_append_tmp_$2" } # Split $1 into $2:$3 splitcolon() { setvar "$2" "${1%%:*}" setvar "$3" "${1#*:}" } # if $1 contains $2 contains() { isnull "${1##*"$2"*}" } # Pick words from each line # $1 return variable name # $2 field list "1,2,3" # $3 input value pick_fields() { local f_line_= f_result_= f_mask_= f_ret_var= f_oifs="$IFS" IFS= f_ret_var=$1 f_mask_=$2 IFS=$Newline for f_line_ in $3 do IFS=$f_oifs # split $f_line_ into words and pick them out set -- $f_line_ f_line_= ! contains "$f_mask_" 1 || f_line_=${1:-} ! contains "$f_mask_" 2 || f_line_="$f_line_ ${2:-}" ! contains "$f_mask_" 3 || f_line_="$f_line_ ${3:-}" append_to @f_result_ "${f_line_# }" done setvar "$f_ret_var" "$f_result_" } # Take all lines matching $2 (full line) # $1 return variable name # $2 filter word # $3 input value # if $1 is a literal `!', the match is reversed (and arguments shift) # we instead remove all lines matching filter_to() { local f_neg= f_line= f_ret= IFS= isnoteq "$1" "!" || { f_neg=negate; shift; } IFS=$Newline for f_line in $3 do $f_neg isnonnull "${f_line##$2}" || f_ret=$f_ret$f_line$Newline done setvar "$1" "${f_ret%$Newline}" } # Output the number of lines in $1 line_count() { local f_x=0 IFS= IFS=$Newline for f_line in $1 do f_x=$(($f_x + 1)) done xecho "$f_x" } ## gitception part # Fetch giturl $1, file $2 gitception_get() { # Take care to preserve FETCH_HEAD local ret_=: obj_id= fet_head="$GIT_DIR/FETCH_HEAD" [ -e "$fet_head" ] && command mv -f "$fet_head" "$fet_head.$$~" || : git fetch -q -f "$1" "$Gref_rbranch:$Gref" >/dev/null && obj_id="$(git ls-tree "$Gref" | xgrep -E '\b'"$2"'$' | awk '{print $3}')" && isnonnull "$obj_id" && git cat-file blob "$obj_id" && ret_=: || { ret_=false && : ; } [ -e "$fet_head.$$~" ] && command mv -f "$fet_head.$$~" "$fet_head" || : $ret_ } anon_commit() { GIT_AUTHOR_NAME="root" GIT_AUTHOR_EMAIL="root@localhost" \ GIT_AUTHOR_DATE="1356994801 -0400" GIT_COMMITTER_NAME="root" \ GIT_COMMITTER_EMAIL="root@localhost" \ GIT_COMMITTER_DATE="1356994801 -0400" \ git commit-tree "$@" </dev/null >&2 || : git rev-parse -q --verify "$Gref" >/dev/null && return 0 || commit_id=$(anon_commit "$empty_tree") && git update-ref "$Gref" "$commit_id" } ## end gitception # Fetch repo $1, file $2, tmpfile in $3 GET() { if isurl sftp "$1" then (exec 0>&-; curl -s -S -k "$1/$2") > "$3" elif isurl rsync "$1" then (exec 0>&-; rsync -I -W "${1#rsync://}"/"$2" "$3" >&2) elif islocalrepo "$1" then cat "$1/$2" > "$3" else gitception_get "${1#gitception://}" "$2" > "$3" fi } # Put repo $1, file $2 or fail, tmpfile in $3 PUT() { if isurl sftp "$1" then curl -s -S -k --ftp-create-dirs -T "$3" "$1/$2" elif isurl rsync "$1" then rsync -I -W "$3" "${1#rsync://}"/"$2" >&2 elif islocalrepo "$1" then cat > "$1/$2" < "$3" else gitception_put "${1#gitception://}" "$2" < "$3" fi } # Put all PUT changes for repo $1 at once PUT_FINAL() { if isurl sftp "$1" || islocalrepo "$1" || isurl rsync "$1" then : else git push --quiet -f "${1#gitception://}" "$Gref:$Gref_rbranch" fi } # Put directory for repo $1 PUTREPO() { if isurl sftp "$1" then : elif isurl rsync "$1" then rsync -q -r --exclude='*' "$Localdir/" "${1#rsync://}" >&2 elif islocalrepo "$1" then mkdir -p "$1" else gitception_new_repo "${1#gitception://}" fi } # For repo $1, delete all newline-separated files in $2 REMOVE() { local fn_= if isurl sftp "$1" then # FIXME echo_info "sftp: Ignore remove request $1/$2" elif isurl rsync "$1" then xecho "$2" | rsync -I -W -v -r --delete --include-from=- \ --exclude='*' "$Localdir"/ "${1#rsync://}/" >&2 elif islocalrepo "$1" then for fn_ in $2; do rm -f "$1"/"$fn_" done else for fn_ in $2; do gitception_remove "${1#gitception://}" "$fn_" done fi } CLEAN_FINAL() { if isurl sftp "$1" || islocalrepo "$1" || isurl rsync "$1" then : else git update-ref -d "$Gref" || : fi } addsignkeyparam() { if isnull "$Conf_signkey"; then "$@" else "$@" -u "$Conf_signkey" fi } ENCRYPT() { gpg --batch --force-mdc --compress-algo none --passphrase-fd 3 -c 3<&1 && status_=$(gpg --status-fd 3 -q -d 3>&1 1>&4) && xecho "$status_" | grep "^\[GNUPG:\] ENC_TO " >/dev/null && (xecho "$status_" | grep -e "$1" >/dev/null || { echo_info "Failed to verify manifest signature!" && echo_info "Only accepting signatories: ${2:-(none)}" && return 1 }) } # Generate $1 random bytes genkey() { gpg --armor --gen-rand 1 "$1" } gpg_hash() { local hash_= hash_=$(gpg --with-colons --print-md "$1" | tr A-F a-f) hash_=${hash_#:*:} xecho "${hash_%:}" } pack_hash() { gpg_hash "$Hashtype"; } # Pass the branch/ref by pipe to git safe_git_rev_parse() { git cat-file --batch-check 2>/dev/null | xgrep -v "missing" | cut -f 1 -d ' ' } make_new_repo() { echo_info "Setting up new repository" PUTREPO "$URL" # Needed assumption: the same user should have no duplicate Repoid Repoid=":id:$(genkey 15)" iseq "${NAME#gcrypt::}" "$URL" || git config "remote.$NAME.gcrypt-id" "$Repoid" echo_info "Remote ID is $Repoid" Extnlist="extn comment" } # $1 return var for goodsig match, $2 return var for signers text read_config() { local recp_= r_keyinfo= cap_= conf_keyring= conf_part= good_sig= signers_= Conf_signkey=$(git config --path user.signingkey || :) conf_keyring=$(git config --path gcrypt.keyring || :) conf_part=$(git config --get "remote.$NAME.gcrypt-participants" '.+' || git config --get gcrypt.participants '.+' || :) # Figure out which keys we should encrypt to or accept signatures from if isnonnull "$conf_keyring" && isnull "$conf_part" then echo_info "WARNING: Setting gcrypt.keyring is deprecated," \ "use gcrypt.participants instead." conf_part=$(gpg --no-default-keyring --keyring "$conf_keyring" \ --with-colons --fast-list -k | grep ^pub | cut -f 5 -d :) fi if isnull "$conf_part" || iseq "$conf_part" simple then signers_="(default keyring)" Recipients="--throw-keyids --default-recipient-self" good_sig="^\[GNUPG:\] GOODSIG " setvar "$1" "$good_sig" setvar "$2" "$signers_" return 0 fi for recp_ in $conf_part do filter_to @r_keyinfo "pub*" \ "$(gpg --with-colons --fast-list -k "$recp_")" isnull "$r_keyinfo" || isnonnull "${r_keyinfo##*"$Newline"*}" || echo_info "WARNING: '$recp_' matches multiple keys, using one" r_keyinfo=${r_keyinfo%%"$Newline"*} keyid_=$(xecho "$r_keyinfo" | cut -f 5 -d :) isnonnull "$keyid_" && signers_="$signers_ $keyid_" && append_to @good_sig "^\[GNUPG:\] GOODSIG $keyid_" || { echo_info "WARNING: Skipping missing key $recp_" continue } # Check 'E'ncrypt capability cap_=$(xecho "$r_keyinfo" | cut -f 12 -d :) iseq "${cap_#*E}" "$cap_" || Recipients="$Recipients -R $keyid_" done if isnull "$Recipients" then echo_info "You have not configured any keys you can encrypt to" \ "for this repository" echo_info "Use ::" echo_info " git config gcrypt.participants YOURKEYID" exit 1 fi setvar "$1" "$good_sig" setvar "$2" "$signers_" } ensure_connected() { local manifest_= r_repoid= r_name= url_frag= r_sigmatch= r_signers= if isnonnull "$Did_find_repo" then return fi Did_find_repo=no read_config @r_sigmatch @r_signers iseq "${NAME#gcrypt::}" "$URL" || r_name=$NAME # Fixup ssh:// -> rsync:// if isurl ssh "$URL"; then URL="rsync://${URL#ssh://}" isnull "$r_name" || { git config "remote.$r_name.url" "gcrypt::$URL" echo_info "Updated URL for $r_name, ssh: -> rsync:" } fi if isurl gitception "$URL" && isnonnull "$r_name"; then git config "remote.$r_name.url" "gcrypt::${URL#gitception://}" echo_info "Updated URL for $r_name, gitception:// -> ()" fi # Find the URL fragment url_frag=${URL##*"#"} isnoteq "$url_frag" "$URL" || url_frag= isnonnull "$url_frag" || { # find old style /G.XXXXXX fragment url_frag=${URL##*/G.} if isnoteq "$url_frag" "$URL"; then URL=${URL%/G."$url_frag"} isnull "$r_name" || { git config "remote.$r_name.url" \ "gcrypt::$URL#$url_frag" echo_info "Updated URL for $r_name, use #fragment" } else url_frag= fi } URL=${URL%"#$url_frag"} # manifestfile -- sha224 hash if we can, else the default location if isurl sftp "$URL" || islocalrepo "$URL" || isurl rsync "$URL" then # not for gitception isnull "$url_frag" || Manifestfile=$(xecho_n "$url_frag" | gpg_hash SHA224) else isnull "$url_frag" || Gref_rbranch="refs/heads/$url_frag" fi Repoid= isnull "$r_name" || Repoid=$(git config "remote.$r_name.gcrypt-id" || :) TmpManifest_Enc="$Localdir/tmp_manifest.$$" GET "$URL" "$Manifestfile" "$TmpManifest_Enc" 2>/dev/null || { echo_info "Repository not found: $URL" return 0 } Did_find_repo=yes echo_info "Decrypting manifest" manifest_=$(PRIVDECRYPT "$r_sigmatch" "$r_signers" < "$TmpManifest_Enc") && isnonnull "$manifest_" || echo_die "Failed to decrypt manifest!" rm -f "$TmpManifest_Enc" filter_to @Refslist "$Hex40 *" "$manifest_" filter_to @Packlist "pack :*:* *" "$manifest_" filter_to @Keeplist "keep :*:*" "$manifest_" filter_to @Extnlist "extn *" "$manifest_" filter_to @r_repoid "repo *" "$manifest_" r_repoid=${r_repoid#repo } r_repoid=${r_repoid% *} if isnull "$Repoid" then echo_info "Remote ID is $r_repoid" Repoid=$r_repoid elif isnoteq "$r_repoid" "$Repoid" then echo_info "WARNING:" echo_info "WARNING: Remote ID has changed!" echo_info "WARNING: from $Repoid" echo_info "WARNING: to $r_repoid" echo_info "WARNING:" Repoid=$r_repoid else return 0 fi isnull "$r_name" || git config "remote.$r_name.gcrypt-id" "$r_repoid" } # $1 is the packline pack :SHA256:abc1231.. fetch_decrypt_pack() { local rcv_id= r_key= r_htype= r_pack= splitcolon "${1#pack :}" @r_htype @r_pack if isnoteq "$r_htype" SHA256 && isnoteq "$r_htype" SHA224 && isnoteq "$r_htype" SHA384 && isnoteq "$r_htype" SHA512 then echo_die "Packline malformed: $1" fi GET "$URL" "$r_pack" "$TmpPack_Encrypted" && rcv_id=$(gpg_hash "$r_htype" < "$TmpPack_Encrypted") && iseq "$rcv_id" "$r_pack" || echo_die "Packfile $r_pack does not match digest!" filter_to @r_key "pack :${r_htype}:$r_pack *" "$Packlist" pick_fields @r_key 3 "$r_key" DECRYPT "$r_key" < "$TmpPack_Encrypted" } # $1 is new pack id $2 key, $3, $4 return variables # set $3 to yes if repacked # $4 to list of packfiles to delete repack_if_needed() { local pack_= packline_= premote_= key_= pkeep_= n_= m_= \ orig_ifs= kline_= r_line= r_list_new= # $TmpPack_Encrypted set in caller setvar "$3" no isnonnull "$Packlist" || return 0 if isnonnull "${GCRYPT_FULL_REPACK:-}" then Keeplist= Repack_limit=1 fi pick_fields @premote_ 1,2 "$Packlist" pick_fields @pkeep_ 2 "$Keeplist" n_=$(line_count "$Packlist") m_=$(line_count "$pkeep_") if [ "$Repack_limit" -gt "$(($n_ - $m_))" ]; then return fi echo_info "Repacking remote $NAME, ..." rm -r -f "$Localdir/pack" mkdir -p "$Localdir/pack" DECRYPT "$2" < "$TmpPack_Encrypted" | git index-pack -v --stdin "$Localdir/pack/${1}.pack" >/dev/null xecho "$premote_" | while read packline_ do isnonnull "$packline_" || continue if isnonnull "$pkeep_" && xecho "$packline_" | grep -q -e "$pkeep_" then continue fi pack_=${packline_#$Packpat} fetch_decrypt_pack "$packline_" | git index-pack -v --stdin "$Localdir/pack/${pack_}.pack" >/dev/null done key_=$(genkey "$Packkey_bytes") git verify-pack -v "$Localdir"/pack/*.idx | grep -E '^[0-9a-f]{40}' | cut -f 1 -d ' ' | GIT_ALTERNATE_OBJECT_DIRECTORIES=$Localdir \ git pack-objects --stdout | ENCRYPT "$key_" > "$TmpPack_Encrypted" # Truncate packlist to only the kept packs if isnull "$pkeep_"; then setvar "$4" "$premote_" Packlist= else setvar "$4" "$(xecho "$premote_" | xgrep -v -e "$pkeep_")" orig_ifs=$IFS IFS=$Newline for kline_ in $pkeep_ do IFS=$orig_ifs filter_to @r_line "pack $kline_ *" "$Packlist" append_to @r_list_new "$r_line" done IFS=$orig_ifs Packlist=$r_list_new fi pack_id=$(pack_hash < "$TmpPack_Encrypted") append_to @Packlist "pack :${Hashtype}:$pack_id $key_" append_to @Keeplist "keep :${Hashtype}:$pack_id 1" rm -r -f "$Localdir/pack" setvar "$3" yes } do_capabilities() { echo_git fetch echo_git push echo_git } do_list() { local obj_id= ref_name= line_= ensure_connected xecho "$Refslist" | while read line_ do isnonnull "$line_" || break obj_id=${line_%% *} ref_name=${line_##* } echo_git "$obj_id" "$ref_name" if iseq "$ref_name" "refs/heads/master" then echo_git "@refs/heads/master HEAD" fi done # end with blank line echo_git } do_fetch() { # The PACK id is the hash of the encrypted git packfile. # We only download packs mentioned in the encrypted manifest, # and check their digest when received. local pack_= packline_= pneed_= premote_= ensure_connected if isnull "$Packlist" then echo_git # end with blank line return fi TmpPack_Encrypted="$Localdir/tmp_pack_ENCRYPTED_.$$" # The `+` for $GITCEPTION is pointless but we will be safe for stacking pick_fields @premote_ 1,2 "$Packlist" if [ -s "$Localdir/have_packs+" ] then pneed_=$(xecho "$premote_" | xgrep -v -x -f "$Localdir/have_packs+") else pneed_=$premote_ fi xecho "$pneed_" | while read packline_ do isnonnull "$packline_" || continue fetch_decrypt_pack "$packline_" | git index-pack -v --stdin >/dev/null # add to local pack list xecho "${packline_}" >> "$Localdir/have_packs$GITCEPTION" done rm -f "$TmpPack_Encrypted" echo_git # end with blank line } # do_push PUSHARGS (multiple lines like +src:dst, with both + and src opt.) do_push() { # Security protocol: # Each git packfile is encrypted and then named for the encrypted # file's hash. The manifest is updated with the pack id. # The manifest is encrypted. local r_revlist= line_= pack_id= key_= obj_= \ r_did_repack= r_pack_delete= r_src= r_dst= ensure_connected if iseq "$Did_find_repo" "no" then make_new_repo fi if isnonnull "$Refslist" then # mark all remote refs with ^ (if sha-1 exists locally) r_revlist=$(xecho "$Refslist" | cut -f 1 -d ' ' | safe_git_rev_parse | sed -e 's/^\(.\)/^&/') fi while read line_ # from << do # +src:dst -- remove leading + then split at : splitcolon "${line_#+}" @r_src @r_dst filter_to ! @Refslist "$Hex40 $r_dst" "$Refslist" if isnonnull "$r_src" then append_to @r_revlist "$r_src" obj_=$(xecho "$r_src" | safe_git_rev_parse) append_to @Refslist "$obj_ $r_dst" fi done <"$TmpPack_Encrypted" # Only send pack if we have any objects to send if [ -s "$TmpObjlist" ] then pack_id=$(pack_hash < "$TmpPack_Encrypted") repack_if_needed "$pack_id" "$key_" @r_did_repack @r_pack_delete if isnoteq "$r_did_repack" yes then append_to @Packlist "pack :${Hashtype}:$pack_id $key_" fi # else, repack rewrote Packlist fi # Generate manifest echo_info "Encrypting to: $Recipients" echo_info "Requesting manifest signature" TmpManifest_Enc="$Localdir/tmp_manifest.$$" PRIVENCRYPT "$Recipients" > "$TmpManifest_Enc" <&2 } # handle git-remote-helpers protocol gcrypt_main_loop() { local input_= input_inner= r_args= NAME=$1 # Remote name URL=$2 # Remote URL mkdir -p "$Localdir" trap cleanup_atexit EXIT trap 'exit 1' 1 2 3 15 echo_info "Development version -- Repository format MAY CHANGE" while read input_ do case "$input_" in capabilities) do_capabilities ;; list|list\ for-push) do_list ;; fetch\ *) r_args=${input_##fetch } while read input_inner do case "$input_inner" in fetch*) r_args= #ignored ;; *) break ;; esac done do_fetch "$r_args" ;; push\ *) r_args=${input_##push } while read input_inner do case "$input_inner" in push\ *) append_to @r_args "${input_inner#push }" ;; *) break ;; esac done do_push "$r_args" ;; ?*) echo_die "Unknown input!" ;; *) CLEAN_FINAL "$URL" exit 0 ;; esac done } gcrypt_main_loop "$@"