diff --git a/sig b/sig index c099ed4..3e41eb9 100755 --- a/sig +++ b/sig @@ -5,6 +5,7 @@ readonly MIN_BASH_VERSION=5 readonly MIN_GPG_VERSION=2.2 readonly MIN_OPENSSL_VERSION=1.1 readonly MIN_GETOPT_VERSION=2.33 +readonly DATE=$(command -v gdate || command -v date) ## Private Functions @@ -224,102 +225,185 @@ group_check_fp(){ fi } +tree_hash() { + mkdir -p ".${PROGRAM}" + printf "%s" "$(get_files | xargs openssl sha256 -r)" \ + | sed -e 's/ \*/ /g' -e 's/ \.\// /g' \ + | LC_ALL=C sort -k2 \ + | openssl sha256 -r \ + | sed -e 's/ .*//g' +} -### Verify a file has 0-N unique valid detached signatures -### Optionally verify all signatures belong to keys in gpg alias group -verify_detached() { - [ $# -eq 3 ] || die "Usage: verify_detached " - local -r threshold="${1}" - local -r group="${2}" - local -r filename="${3}" - local fp uid sig_count=0 seen_fps="" +sig_generate(){ + local -r vcs_ref="$1" + local -r review_hash="${2:-null}" + local -r version="v0" + local -r sig_type="pgp" + local -r tree_hash="$(tree_hash)" + local -r body="sig:$version:$vcs_ref:$tree_hash:$review_hash:$sig_type" + local -r signature=$(\ + printf "%s" "$body" \ + | gpg \ + --detach-sign \ + --local-user "$key" \ + | openssl base64 -A \ + ) + printf "$body:$signature" +} - for sig_filename in "${filename%.*}".*.asc; do - gpg --verify "${sig_filename}" "${filename}" >/dev/null 2>&1 || { - echo "Invalid detached signature: ${sig_filename}"; - return 1; - } - file_fp=$( get_file_fp "${sig_filename}" ) - fp=$( get_primary_fp "${file_fp}" ) - uid=$( get_uid "${fp}" ) +parse_gpg_status() { + local -r gpg_status="$1" + while read -r values; do + local key array sip_fp sig_date sig_status sig_author sig_body + IFS=" " read -r -a array <<< "$values" + key=${array[1]} + case $key in + "BADSIG"|"ERRSIG"|"EXPSIG"|"EXPKEYSIG"|"REVKEYSIG") + sig_fp="${array[2]}" + sig_status="$key" + ;; + "GOODSIG") + sig_author="${values:34}" + sig_fp="${array[2]}" + ;; + "VALIDSIG") + sig_status="$key" + sig_date="${array[4]}" + ;; + "SIG_ID") + sig_date="${array[4]}" + ;; + "NEWSIG") + sig_author="${sig_author:-Unknown User <${array[2]}>}" + ;; + TRUST_*) + sig_trust="${key//TRUST_/}" + ;; + esac + done <<< "$gpg_status" + sig_fp=$(get_primary_fp $sig_fp) + sig_body="pgp:$sig_fp:$sig_status:$sig_trust:$sig_date:$sig_author" + printf "$sig_body" - [[ "${seen_fps}" == *"${fp}"* ]] && { - echo "Duplicate signature: ${sig_filename}"; - return 1; - } +} - echo "Verified detached signature by \"${uid}\"" - - if [ ! -z "${group}" ]; then - group_check_fp "${fp}" "${group}" || { - echo "Detached signing key not in group \"${group}\": ${fp}"; - return 1; - } - fi - - seen_fps="${seen_fps} ${fp}" - ((sig_count=sig_count+1)) - done - [[ "${sig_count}" -ge "${threshold}" ]] || { - echo "Minimum detached signatures not found: ${sig_count}/${threshold}"; +verify_git_note(){ + local -r line="${1}" + IFS=':' line_parts=($line) + local -r identifier=${line_parts[0]} + local -r version=${line_parts[1]} + local -r vcs_hash=${line_parts[2]} + local -r tree_hash=${line_parts[3]} + local -r review_hash=${line_parts[4]:-null} + local -r sig_type=${line_parts[5]} + local -r sig=${line_parts[6]} + local -r body="sig:$version:$vcs_hash:$tree_hash:$review_hash:$sig_type" + local sig_status="unknown" + [[ "$identifier" == "sig" \ + && "$version" == "v0" \ + && "$sig_type" == "pgp" \ + ]] || { return 1; } + [[ "$tree_hash" == "$(tree_hash)" ]] || { + echo "Actual tree hash differs from signature."; + return 1; + } + gpg_sig_raw="$( + gpg --verify --status-fd=1 \ + <(printf '%s' "$sig" | openssl base64 -d -A) \ + <(printf '%s' "$body") 2>/dev/null \ + )" + parse_gpg_status "$gpg_sig_raw" +} + +verify_git_notes(){ + local -r commit=$(git rev-parse --short HEAD) + local code=1 + while IFS='' read -r line; do + printf "$(verify_git_note "$line")\n" + code=0 + done < <(git notes --ref signatures show "$commit" 2>&1 | grep "^sig:") + return $code +} + +verify_git_commit(){ + local gpg_sig_raw + gpg_sig_raw=$(git verify-commit HEAD --raw 2>&1) + parse_gpg_status "$gpg_sig_raw" +} + +verify_git_tags(){ + local fps="" git_sig_raw code=1 + for tag in $(git tag --points-at HEAD); do + git tag --verify "$tag" >/dev/null 2>&1 && { + gpg_sig_raw=$( git verify-tag --raw "$tag" 2>&1 ) + printf "$(parse_gpg_status "$gpg_sig_raw")\n" + code=0 + } + done + return $code } ### Verify head commit is signed -### Optionally verify unique signed git tags to meet a threshold +### Optionally verify total unique commit/tag/note signatures meet a threshold ### Optionally verify all signatures belong to keys in gpg alias group verify_git(){ [ $# -eq 2 ] || die "Usage: verify_git " local -r threshold="${1}" local -r group="${2}" - local seen_fps="" sig_count=0 ref commit git_fp fp uid - + local sig_count=0 seen_fps="" fp="" tag_fps="" note_fps="" + if [[ $(git diff --stat) != '' ]]; then + die "Error: git tree is dirty" + fi git verify-commit HEAD >/dev/null 2>&1 \ - || die "HEAD commit not signed" + || die "Error: HEAD commit is not signed" - git_fp=$(git log --format="%GP" HEAD -n1 ) - fp=$(get_primary_fp "$git_fp") - seen_fps="${fp}" - sig_count=1 - uid=$( get_uid "${fp}" ) - echo "Verified signed git commit by \"${uid}\"" + commit_sig=$(verify_git_commit) + if [ -n "$commit_sig" ]; then + IFS=':' read -r -a sig <<< "$commit_sig" + fp="${sig[1]}" + uid="${sig[5]}" + echo "Verified signed git commit by \"${uid}\"" + seen_fps="${fp}" + fi - for tag in $(git tag --points-at HEAD); do - git tag --verify "$tag" >/dev/null 2>&1 && { - git_fp=$( \ - git verify-tag --raw "$tag" 2>&1 \ - | grep VALIDSIG \ - | awk '{print $3}' \ - ) - fp=$(get_primary_fp "$git_fp") - uid=$( get_uid "${fp}" ) - if [[ "${seen_fps}" != *"${fp}"* ]]; then - seen_fps="${seen_fps} ${fp}" - echo "Verified signed git tag by \"${uid}\"" - ((sig_count=sig_count+1)) - fi - } - done + tag_sigs=$(verify_git_tags) + [[ $? == 0 ]] && while IFS= read -r line; do + IFS=':' read -r -a sig <<< "$line" + fp="${sig[1]}" + uid="${sig[5]}" + echo "Verified signed git tag by \"${uid}\"" + if [[ "${seen_fps}" != *"${fp}"* ]]; then + seen_fps+=" ${fp}" + fi + done <<< "$tag_sigs" - [[ "${sig_count}" -ge "${threshold}" ]] || { - echo "Minimum git signatures not found: ${sig_count}/${threshold}"; - return 1; - } + note_sigs=$(verify_git_notes) + [[ $? == 0 ]] && while IFS= read -r line; do + IFS=':' read -r -a sig <<< "$line" + fp="${sig[1]}" + uid="${sig[5]}" + echo "Verified signed git note by \"${uid}\"" + if [[ "${seen_fps}" != *"${fp}"* ]]; then + seen_fps+=" ${fp}" + fi + done <<< "$note_sigs" - if [ ! -z "$group" ]; then - for seen_fp in ${seen_fps}; do + for fp in ${seen_fps}; do + if [ ! -z "$group" ]; then group_check_fp "${seen_fp}" "${group}" || { echo "Git signing key not in group \"${group}\": ${fp}"; return 1; } - done - fi - - if [[ $(git diff --stat) != '' ]]; then - die "Error: git tree is dirty" - fi + fi + ((sig_count=sig_count+1)) + done + [[ "${sig_count}" -ge "${threshold}" ]] || { + echo "Minimum unique signatures not found: ${sig_count}/${threshold}"; + return 1; + } } ## Get temporary dir reliably across different mktemp implementations @@ -333,7 +417,6 @@ get_temp(){ --directory } - ## Verify specified branch and show diff between that and current HEAD verify_git_diff(){ [ $# -eq 4 ] \ @@ -353,58 +436,18 @@ verify_git_diff(){ if verify "${threshold}" "${group}" "${method}"; then git --no-pager diff "${diff_ref}" "${curr_ref}" else - echo "Verification of specifed diff ref failed: ${ref}" + echo "Verification of specified diff ref failed: ${ref}" fi } ## Verify current folder/repo with specified signing rules verify(){ - [ $# -eq 3 ] || die "Usage: verify " + [ $# -eq 3 ] || die "Usage: verify " local -r threshold=${1} local -r group=${2} - local -r method=${3} - if [ -z "$method" ] || [ "$method" == "git" ]; then - if [ "$method" == "git" ]; then - command -v git >/dev/null 2>&1 \ - || die "Error: method 'git' specified and git is not installed" - [ -d .git ] \ - || die "Error: This folder is not a git repository" - fi - if command -v git >/dev/null 2>&1 \ - && ( [ -d .git ] || git rev-parse --git-dir > /dev/null 2>&1 ); - then - verify_git "${threshold}" "${group}" || return 1 - fi - fi - - if [ -z "$method" ] || [ "$method" == "detached" ]; then - if [ "$method" == "detached" ]; then - ( [ -d ".${PROGRAM}" ] && ls ."${PROGRAM}"/*.asc >/dev/null 2>&1 \ - ) || { - echo "Error: method 'detached' and no signatures found"; - return 1; - } - fi - if ( \ - [ -d ".${PROGRAM}" ] && ls ."${PROGRAM}"/*.asc >/dev/null 2>&1 \ - ); then - cmd_manifest || return 1; - verify_detached "${threshold}" "${group}" ."${PROGRAM}"/manifest.txt \ - || return 1; - fi - fi -} - -## Add detached signature for contents of this folder -sign_detached(){ - cmd_manifest - gpg --armor --detach-sig ."${PROGRAM}"/manifest.txt >/dev/null 2>&1 - local -r fp=$( \ - gpg --list-packets ."${PROGRAM}"/manifest.txt.asc \ - | grep "issuer key ID" \ - | sed 's/.*\([A-Z0-9]\{16\}\).*/\1/g' \ - ) - mv ."${PROGRAM}"/manifest.{"txt.asc","${fp}.asc"} + [ -d .git ] \ + || die "Error: This folder is not a git repository" + verify_git "${threshold}" "${group}" || return 1 } ## Add signed tag pointing at this commit. @@ -424,9 +467,29 @@ sign_tag(){ ) local -r name="sig-${commit}-${fp}" git tag -fsm "$name" "$name" - [[ $push -eq 0 ]] || git push --tags + [[ "$push" -eq "0" ]] || $PROGRAM push } +## Add signed git note to this commit +## Optionally push to origin. +sign_note() { + [ -d '.git' ] \ + || die "Not a git repository" + command -v git >/dev/null \ + || die "Git not installed" + git config --get user.signingKey >/dev/null \ + || die "Git user.signingKey not set" + local -r push="${1}" + local -r key=$( \ + git config --get user.signingKey \ + | sed 's/.*\([A-Z0-9]\{16\}\).*/\1/g' \ + ) + local -r commit=$(git rev-parse HEAD) + + sig_generate "$commit" | git notes --ref signatures append --file=- + + [[ "$push" -eq "0" ]] || $PROGRAM push +} ## Public Commands @@ -513,25 +576,22 @@ cmd_add(){ --) shift; break ;; esac done case $method in - detached) sign_detached ;; - git) sign_tag "$push" ;; - *) - [ ! -z "$push" ] || cmd_usage - if [ -d '.git' ]; then - sign_tag "$push" - else - sign_detached - fi - ;; + git) sign_note "$push" ;; + *) sign_note "$push" ;; esac } +cmd_push() { + [ "$#" -eq 0 ] || { usage push; exit 1; } + git push --tags origin refs/notes/signatures +} + cmd_version() { cat <<-_EOF ============================================== = sig: simple multisig trust toolchain = = = - = v0.0.1 = + = v0.2 = = = = https://github.com/distrust-foundation/sig = ============================================== @@ -542,14 +602,12 @@ cmd_usage() { cmd_version cat <<-_EOF Usage: - $PROGRAM add [-m,--method=] [-p,--push] - Add signature to manifest for this directory - $PROGRAM verify [-g,--group=] [-t,--threshold=] [-m,--method= ] [d,--diff=] + $PROGRAM add [-m,--method=] [-p,--push] + Add signature for this repository + $PROGRAM verify [-g,--group=] [-t,--threshold=] [d,--diff=] Verify m-of-n signatures by given group are present for directory. $PROGRAM fetch [-g,--group=] Fetch key by fingerprint. Optionally add to group. - $PROGRAM manifest - Generate hash manifest for this directory $PROGRAM help Show this text. $PROGRAM version @@ -558,7 +616,7 @@ cmd_usage() { } # Verify all tools in this list are installed at needed versions -check_tools head cut find sort sed getopt gpg openssl +check_tools git head cut find sort sed getopt gpg openssl # Allow entire script to be namespaced based on filename readonly PROGRAM="${0##*/}" @@ -569,6 +627,7 @@ case "$1" in add) shift; cmd_add "$@" ;; manifest) shift; cmd_manifest "$@" ;; fetch) shift; cmd_fetch "$@" ;; + push) shift; cmd_push "$@" ;; version|--version) shift; cmd_version "$@" ;; help|--help) shift; cmd_usage "$@" ;; *) cmd_usage "$@" ;; diff --git a/test/test.bats b/test/test.bats index 5a4d086..a72b263 100644 --- a/test/test.bats +++ b/test/test.bats @@ -15,7 +15,7 @@ load test_helper @test "Outputs version if run with version" { run sig version [ "$status" -eq 0 ] - echo "${output}" | grep "v0.0.1" + echo "${output}" | grep "v0.2" } @test "Outputs advice to install missing openssl" { @@ -36,34 +36,6 @@ load test_helper echo "${output}" | grep "apt install getopt" } -@test "Can generate manifest for git repo" { - set_identity "user1" - echo "test string" > somefile - git init - git add . - git commit -m "initial commit" - sig manifest - run grep -q "1" <(wc -l .sig/manifest.txt) - [ "$status" -eq 0 ] - run grep 37d2046a395cbfc .sig/manifest.txt - [ "$status" -eq 0 ] -} - -@test "Can generate manifest for folder with git not installed" { - sudo rm /usr/bin/git - echo "test string" > somefile - sig manifest - run grep 37d2046a395cbfc .sig/manifest.txt - [ "$status" -eq 0 ] -} - -@test "Can generate manifest for folder with git installed" { - echo "test string" > somefile - sig manifest - run grep 37d2046a395cbfc .sig/manifest.txt - [ "$status" -eq 0 ] -} - @test "Verify fails if git is in use and tree is dirty" { set_identity "user1" echo "test string" > somefile @@ -71,22 +43,22 @@ load test_helper git add . git commit -m "initial commit" echo "dirty" > somefile - run sig verify --method="git" + run sig verify [ "$status" -eq 1 ] } @test "Exit 1 if git method requested but not a repo" { - run sig verify --method="git" + run sig verify [ "$status" -eq 1 ] } -@test "Verify succeeds when 1 unique git sig requirement is satisifed" { +@test "Verify succeeds when 1 unique git sig requirement is satisfied" { set_identity "user1" echo "test string" > somefile git init git add . git commit -m "initial commit" - run sig verify --method git + run sig verify [ "$status" -eq 0 ] } @@ -101,7 +73,7 @@ load test_helper sig add set_identity "user3" sig add - run sig verify --method git --threshold 3 + run sig verify --threshold 3 [ "$status" -eq 0 ] } @@ -112,7 +84,7 @@ load test_helper git add . git commit -m "user1 commit" sig add - run sig verify --method git --threshold 2 + run sig verify --threshold 2 [ "$status" -eq 1 ] } @@ -123,7 +95,7 @@ load test_helper git add . git commit -m "initial commit" sig fetch --group maintainers AE08157232C35F04309FA478C5EBC4A7CF55A2D0 - run sig verify --method git --group maintainers + run sig verify --group maintainers [ "$status" -eq 0 ] } @@ -140,7 +112,7 @@ load test_helper sig fetch --group maintainers AE08157232C35F04309FA478C5EBC4A7CF55A2D0 sig fetch --group maintainers BE4D60F6CFD2237A8AF978583C51CADD33BD0EE8 sig fetch --group maintainers 3E45AC9E190B4EE32BAE9F61A331AFB540761D69 - run sig verify --method git --threshold 3 --group maintainers + run sig verify --threshold 3 --group maintainers [ "$status" -eq 0 ] } @@ -150,59 +122,8 @@ load test_helper git init git add . git commit -m "initial commit" - run sig verify --method git --threshold 2 --group maintainers - [ "$status" -eq 1 ] -} - -@test "Verify succeeds when 1 unique detached sig requirement is satisifed" { - set_identity "user1" - run sig add - run sig verify --method detached - [ "$status" -eq 0 ] -} - -@test "Verify succeeds when 2 unique detached sig requirement is satisifed" { - set_identity "user1" - run sig add - set_identity "user2" - run sig add - run sig verify --threshold 2 --method detached - [ "$status" -eq 0 ] -} - -@test "Verify fails when 2 unique detached sig requirement is not satisifed" { - set_identity "user1" - run sig add - run sig verify --threshold 2 --method detached - [ "$status" -eq 1 ] -} - -@test "Verify succeeds when 1 group detached sig requirement is satisifed" { - set_identity "user1" - sig add sig fetch --group maintainers AE08157232C35F04309FA478C5EBC4A7CF55A2D0 - run sig verify --method detached --group maintainers - [ "$status" -eq 0 ] -} - -@test "Verify succeeds when 3 group detached sig requirement is satisifed" { - set_identity "user1" - sig add - set_identity "user2" - sig add - set_identity "user3" - sig add - sig fetch --group maintainers AE08157232C35F04309FA478C5EBC4A7CF55A2D0 - sig fetch --group maintainers BE4D60F6CFD2237A8AF978583C51CADD33BD0EE8 - sig fetch --group maintainers 3E45AC9E190B4EE32BAE9F61A331AFB540761D69 - run sig verify --method detached --threshold 3 --group maintainers - [ "$status" -eq 0 ] -} - -@test "Verify fails when 2 group detached sig requirement is not satisifed" { - set_identity "user1" - sig add - run sig verify --method detached --threshold 2 --group maintainers + run sig verify --threshold 2 --group maintainers [ "$status" -eq 1 ] }