git-sig 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. #! /usr/bin/env bash
  2. set -e
  3. readonly MIN_BASH_VERSION=5
  4. readonly MIN_GPG_VERSION=2.2
  5. readonly MIN_OPENSSL_VERSION=1.1
  6. readonly MIN_GETOPT_VERSION=2.33
  7. GIT_SIG_GPG_VERIFY_COMMAND=${GIT_SIG_GPG_VERIFY_COMMAND:-gpg}
  8. GIT_SIG_SIGN_COMMAND=${GIT_SIG_SIGN_COMMAND:-gpg}
  9. ## Private Functions
  10. ### Exit with error message
  11. die() {
  12. echo "$@" >&2
  13. exit 1
  14. }
  15. ### Bail and instruct user on missing package to install for their platform
  16. die_pkg() {
  17. local -r package=${1?}
  18. local -r version=${2?}
  19. local install_cmd
  20. case "$OSTYPE" in
  21. linux*)
  22. if command -v "apt" >/dev/null; then
  23. install_cmd="apt install ${package}"
  24. elif command -v "yum" >/dev/null; then
  25. install_cmd="yum install ${package}"
  26. elif command -v "pacman" >/dev/null; then
  27. install_cmd="pacman -Ss ${package}"
  28. elif command -v "emerge" >/dev/null; then
  29. install_cmd="emerge ${package}"
  30. elif command -v "nix-env" >/dev/null; then
  31. install_cmd="nix-env -i ${package}"
  32. fi
  33. ;;
  34. bsd*) install_cmd="pkg install ${package}" ;;
  35. darwin*) install_cmd="port install ${package}" ;;
  36. *) die "Error: Your operating system is not supported" ;;
  37. esac
  38. echo "Error: ${package} ${version}+ does not appear to be installed." >&2
  39. [ -n "$install_cmd" ] && echo "Try: \`${install_cmd}\`" >&2
  40. exit 1
  41. }
  42. ### Ask user to make a binary decision
  43. ### If not an interactive terminal: auto-accept default
  44. ask() {
  45. local prompt default
  46. while true; do
  47. prompt=""
  48. default=""
  49. if [ "${2}" = "Y" ]; then
  50. prompt="Y/n"
  51. default=Y
  52. elif [ "${2}" = "N" ]; then
  53. prompt="y/N"
  54. default=N
  55. else
  56. prompt="y/n"
  57. default=
  58. fi
  59. printf "\\n%s [%s] " "$1" "$prompt"
  60. read -r reply
  61. [ -z "$reply" ] && reply=$default
  62. case "$reply" in
  63. Y*|y*) return 0 ;;
  64. N*|n*) return 1 ;;
  65. esac
  66. done
  67. }
  68. ### Check if actual binary version is >= minimum version
  69. check_version(){
  70. local pkg="${1?}"
  71. local have="${2?}"
  72. local need="${3?}"
  73. local i ver1 ver2 IFS='.'
  74. [[ "$have" == "$need" ]] && return 0
  75. read -r -a ver1 <<< "$have"
  76. read -r -a ver2 <<< "$need"
  77. for ((i=${#ver1[@]}; i<${#ver2[@]}; i++));
  78. do ver1[i]=0;
  79. done
  80. for ((i=0; i<${#ver1[@]}; i++)); do
  81. [[ -z ${ver2[i]} ]] && ver2[i]=0
  82. ((10#${ver1[i]} > 10#${ver2[i]})) && return 0
  83. ((10#${ver1[i]} < 10#${ver2[i]})) && die_pkg "${pkg}" "${need}"
  84. done
  85. }
  86. ### Check if required binaries are installed at appropriate versions
  87. check_tools(){
  88. if [ -z "${BASH_VERSINFO[0]}" ] \
  89. || [ "${BASH_VERSINFO[0]}" -lt "${MIN_BASH_VERSION}" ]; then
  90. die_pkg "bash" "${MIN_BASH_VERSION}"
  91. fi
  92. for cmd in "$@"; do
  93. command -v "$1" >/dev/null || die "Error: $cmd not found"
  94. case $cmd in
  95. ${GIT_SIG_VERIFY_COMMAND})
  96. version=$(gpg --version | head -n1 | cut -d" " -f3)
  97. check_version "gnupg" "${version}" "${MIN_GPG_VERSION}"
  98. ;;
  99. openssl)
  100. version=$(openssl version | cut -d" " -f2 | sed 's/[a-z]//g')
  101. check_version "openssl" "${version}" "${MIN_OPENSSL_VERSION}"
  102. ;;
  103. getopt)
  104. version=$(getopt --version | cut -d" " -f4 | sed 's/[a-z]//g')
  105. check_version "getopt" "${version}" "${MIN_GETOPT_VERSION}"
  106. ;;
  107. esac
  108. done
  109. }
  110. ### Get primary UID for a given fingerprint
  111. get_uid(){
  112. local -r fp="${1?}"
  113. ${GIT_SIG_VERIFY_COMMAND} --list-keys --with-colons "${fp}" 2>&1 \
  114. | awk -F: '$1 == "uid" {print $10}' \
  115. | head -n1
  116. }
  117. ### Get primary fingerprint for given search
  118. get_primary_fp(){
  119. local -r search="${1?}"
  120. ${GIT_SIG_VERIFY_COMMAND} --list-keys --with-colons "${search}" 2>&1 \
  121. | awk -F: '$1 == "fpr" {print $10}' \
  122. | head -n1
  123. }
  124. ### Get fingerprint for a given pgp file
  125. get_file_fp(){
  126. local -r filename="${1?}"
  127. ${GIT_SIG_VERIFY_COMMAND} --list-packets "${filename}" \
  128. | grep keyid \
  129. | sed 's/.*keyid //g'
  130. }
  131. ### Get raw gpgconf group config
  132. group_get_config(){
  133. local -r config=$(gpgconf --list-options gpg | grep ^group)
  134. printf '%s' "${config##*:}"
  135. }
  136. ### Add fingerprint to a given group
  137. group_add_fp(){
  138. local -r fp=${1?}
  139. local -r group_name=${2?}
  140. local -r config=$(group_get_config)
  141. local group_names=()
  142. local member_lists=()
  143. local name member_list config i data
  144. while IFS=' =' read -rd, name member_list; do
  145. group_names+=("${name:1}")
  146. member_lists+=("$member_list")
  147. done <<< "$config,"
  148. printf '%s\n' "${group_names[@]}" \
  149. | grep -w "${group_name}" \
  150. || group_names+=("${group_name}")
  151. for i in "${!group_names[@]}"; do
  152. [ "${group_names[$i]}" == "${group_name}" ] \
  153. && member_lists[$i]="${member_lists[$i]} ${fp}"
  154. data+=$(printf '"%s = %s,' "${group_names[$i]}" "${member_lists[$i]}")
  155. done
  156. echo "Adding key \"${fp}\" to group \"${group_name}\""
  157. ${GIT_SIG_VERIFY_COMMAND} --list-keys >/dev/null 2>&1
  158. printf 'group:0:%s' "${data%?}" \
  159. | gpgconf --change-options gpg >/dev/null 2>&1
  160. }
  161. ### Get fingerprints for a given group
  162. group_get_fps(){
  163. local -r group_name=${1?}
  164. ${GIT_SIG_VERIFY_COMMAND} --with-colons --list-config group \
  165. | grep -i "^cfg:group:${group_name}:" \
  166. | cut -d ':' -f4
  167. }
  168. ### Check if fingerprint belongs to a given group
  169. ### Give user option to add it if they wish
  170. group_check_fp(){
  171. local -r fp=${1?}
  172. local -r group_name=${2?}
  173. local -r group_fps=$(group_get_fps "${group_name}")
  174. local -r uid=$(get_uid "${fp}")
  175. if [ -z "$group_fps" ] \
  176. || [[ "${group_fps}" != *"${fp}"* ]]; then
  177. cat <<-_EOF
  178. The following key is not a member of group "${group_name}":
  179. Fingerprint: ${fp}
  180. Primary UID: ${uid}
  181. _EOF
  182. if ask "Add key to group \"${group_name}\" ?" "N"; then
  183. group_add_fp "${fp}" "${group_name}"
  184. else
  185. return 1
  186. fi
  187. fi
  188. }
  189. tree_hash() {
  190. local -r ref="${1:-HEAD}"
  191. git rev-parse "${ref}^{tree}"
  192. }
  193. sig_generate(){
  194. local -r vcs_ref="$1"
  195. local -r review_hash="${2:-null}"
  196. local -r version="v0"
  197. local -r sig_type="pgp"
  198. local -r tree_hash="$(tree_hash)"
  199. local -r body="sig:$version:$vcs_ref:$tree_hash:$review_hash:$sig_type"
  200. local -r signature=$(\
  201. printf "%s" "$body" \
  202. | ${GIT_SIG_SIGN_COMMAND} \
  203. --detach-sign \
  204. --local-user "$key" \
  205. | openssl base64 -A \
  206. )
  207. printf "%s" "$body:$signature"
  208. }
  209. parse_gpg_status() {
  210. local -r gpg_status="$1"
  211. local -r error="$2"
  212. while read -r values; do
  213. local key array sig_fp sig_date sig_status sig_author sig_body
  214. IFS=" " read -r -a array <<< "$values"
  215. key=${array[1]}
  216. case $key in
  217. "BADSIG"|"ERRSIG"|"EXPSIG"|"EXPKEYSIG"|"REVKEYSIG")
  218. sig_fp="${array[2]}"
  219. sig_status="$key"
  220. ;;
  221. "GOODSIG")
  222. sig_author="${values:34}"
  223. sig_fp="${array[2]}"
  224. ;;
  225. "VALIDSIG")
  226. sig_status="$key"
  227. sig_date="${array[4]}"
  228. ;;
  229. "SIG_ID")
  230. sig_date="${array[4]}"
  231. ;;
  232. "NEWSIG")
  233. sig_author="${sig_author:-Unknown User <${array[2]}>}"
  234. ;;
  235. TRUST_*)
  236. sig_trust="${key//TRUST_/}"
  237. ;;
  238. esac
  239. done <<< "$gpg_status"
  240. sig_fp=$(get_primary_fp "$sig_fp")
  241. sig_body="pgp:$sig_fp:$sig_status:$sig_trust:$sig_date:$sig_author:$error"
  242. printf "%s" "$sig_body"
  243. }
  244. verify_git_note(){
  245. local -r line="${1}"
  246. local -r ref="${2:-HEAD}"
  247. local -r commit=$(git rev-parse "$ref")
  248. IFS=':' read -r -a line_parts <<< "$line"
  249. local -r identifier=${line_parts[0]}
  250. local -r version=${line_parts[1]}
  251. local -r vcs_hash=${line_parts[2]}
  252. local -r tree_hash=${line_parts[3]}
  253. local -r review_hash=${line_parts[4]:-null}
  254. local -r sig_type=${line_parts[5]}
  255. local -r sig=${line_parts[6]}
  256. local -r body="sig:$version:$vcs_hash:$tree_hash:$review_hash:$sig_type"
  257. local error="" commit_tree_hash
  258. [[ "$identifier" == "sig" \
  259. && "$version" == "v0" \
  260. && "$sig_type" == "pgp" \
  261. ]] || {
  262. return 1;
  263. }
  264. gpg_sig_raw="$(
  265. ${GIT_SIG_VERIFY_COMMAND} --verify --status-fd=1 \
  266. <(printf '%s' "$sig" | openssl base64 -d -A) \
  267. <(printf '%s' "$body") 2>/dev/null \
  268. )"
  269. [[ "$vcs_hash" == "$commit" ]] || {
  270. error="COMMIT_NOMATCH"
  271. }
  272. commit_tree_hash=$(tree_hash "$commit")
  273. [[ "$tree_hash" == "$commit_tree_hash" ]] || {
  274. error="TREEHASH_NOMATCH;$commit;$tree_hash;$commit_tree_hash";
  275. }
  276. parse_gpg_status "$gpg_sig_raw" "$error"
  277. }
  278. verify_git_notes(){
  279. local -r ref="${1:-HEAD}"
  280. local -r commit=$(git rev-parse "$ref")
  281. local code=1
  282. while IFS='' read -r line; do
  283. printf "%s\n" "$(verify_git_note "$line" "$ref")"
  284. code=0
  285. done < <(git notes --ref signatures show "$commit" 2>&1 | grep "^sig:")
  286. return $code
  287. }
  288. verify_git_commit(){
  289. local -r ref="${1:-HEAD}"
  290. local gpg_sig_raw
  291. gpg_sig_raw=$( \
  292. git \
  293. -c "gpg.program=$GIT_SIG_VERIFY_COMMAND" \
  294. verify-commit "$ref" \
  295. --raw \
  296. 2>&1 \
  297. )
  298. parse_gpg_status "$gpg_sig_raw"
  299. }
  300. verify_git_tags(){
  301. local gpg_sig_raw code=1
  302. for tag in $(git tag --points-at HEAD); do
  303. git tag --verify "$tag" >/dev/null 2>&1 && {
  304. gpg_sig_raw=$( git verify-tag --raw "$tag" 2>&1 )
  305. printf "%s\n" "$(parse_gpg_status "$gpg_sig_raw")"
  306. code=0
  307. }
  308. done
  309. return $code
  310. }
  311. ### Verify head commit is signed
  312. ### Optionally verify total unique commit/tag/note signatures meet a threshold
  313. ### Optionally verify all signatures belong to keys in gpg alias group
  314. verify(){
  315. [ $# -eq 3 ] || die "Usage: verify <threshold> <group> <ref>"
  316. local -r threshold="${1}"
  317. local -r group="${2}"
  318. local -r ref=${3:-HEAD}
  319. local sig_count=0 seen_fps fp commit_sig tag_sigs note_sigs
  320. git rev-parse --git-dir >/dev/null 2>&1 \
  321. || die "Error: This folder is not a git repository"
  322. if [[ $(git diff --stat) != '' ]]; then
  323. die "Error: git tree is dirty"
  324. fi
  325. commit_sig=$(verify_git_commit "$ref")
  326. if [ -n "$commit_sig" ]; then
  327. IFS=':' read -r -a sig <<< "$commit_sig"
  328. fp="${sig[1]}"
  329. uid="${sig[5]}"
  330. echo "Verified signed git commit by \"$uid\""
  331. seen_fps="${fp}"
  332. fi
  333. tag_sigs=$(verify_git_tags "$ref") && \
  334. while IFS= read -r line; do
  335. IFS=':' read -r -a sig <<< "$line"
  336. fp="${sig[1]}"
  337. uid="${sig[5]}"
  338. echo "Verified signed git tag by \"${uid}\""
  339. if [[ "${seen_fps}" != *"${fp}"* ]]; then
  340. seen_fps+=" ${fp}"
  341. fi
  342. done <<< "$tag_sigs"
  343. note_sigs=$(verify_git_notes "$ref") && \
  344. while IFS= read -r line; do
  345. IFS=':' read -r -a sig <<< "$line"
  346. fp="${sig[1]}"
  347. uid="${sig[5]}"
  348. error="${sig[6]}"
  349. [ "$error" == "" ] || {
  350. echo "Error: $error";
  351. return 1;
  352. }
  353. echo "Verified signed git note by \"${uid}\""
  354. if [[ "${seen_fps}" != *"${fp}"* ]]; then
  355. seen_fps+=" ${fp}"
  356. fi
  357. done <<< "$note_sigs"
  358. for seen_fp in ${seen_fps}; do
  359. if [ -n "$group" ]; then
  360. group_check_fp "${seen_fp}" "${group}" || {
  361. echo "Git signing key not in group \"${group}\": ${seen_fp}";
  362. return 1;
  363. }
  364. fi
  365. ((sig_count=sig_count+1))
  366. done
  367. [[ "${sig_count}" -ge "${threshold}" ]] || {
  368. echo "Minimum unique signatures not found: ${sig_count}/${threshold}";
  369. return 1;
  370. }
  371. }
  372. ## Get temporary dir reliably across different mktemp implementations
  373. get_temp(){
  374. mktemp \
  375. --quiet \
  376. --directory \
  377. -t "$(basename "$0").XXXXXX" 2>/dev/null \
  378. || mktemp \
  379. --quiet \
  380. --directory
  381. }
  382. ## Add signed tag pointing at this commit.
  383. ## Optionally push to origin.
  384. sign_tag(){
  385. git rev-parse --git-dir >/dev/null 2>&1 \
  386. || die "Not a git repository"
  387. command -v git >/dev/null \
  388. || die "Git not installed"
  389. git config --get user.signingKey >/dev/null \
  390. || die "Git user.signingKey not set"
  391. local -r push="${1}"
  392. local -r commit=$(git rev-parse --short HEAD)
  393. local -r fp=$( \
  394. git config --get user.signingKey \
  395. | sed 's/.*\([A-Z0-9]\{16\}\).*/\1/g' \
  396. )
  397. local -r name="sig-${commit}-${fp}"
  398. git tag -fsm "$name" "$name"
  399. [[ "$push" -eq "0" ]] || $PROGRAM push
  400. }
  401. ## Add signed git note to this commit
  402. ## Optionally push to origin.
  403. sign_note() {
  404. git rev-parse --git-dir >/dev/null 2>&1 \
  405. || die "Not a git repository"
  406. command -v git >/dev/null \
  407. || die "Git not installed"
  408. git config --get user.signingKey >/dev/null \
  409. || die "Git user.signingKey not set"
  410. local -r push="${1}"
  411. local -r key=$( \
  412. git config --get user.signingKey \
  413. | sed 's/.*\([A-Z0-9]\{16\}\).*/\1/g' \
  414. )
  415. local -r commit=$(git rev-parse HEAD)
  416. sig_generate "$commit" | git notes --ref signatures append --file=-
  417. [[ "$push" -eq "0" ]] || $PROGRAM push
  418. }
  419. ## Public Commands
  420. cmd_remove() {
  421. git notes --ref signatures remove
  422. }
  423. cmd_verify() {
  424. local opts threshold=1 remote="origin" group="" method="" diff=""
  425. opts="$(getopt -o t:g:m:o:d:: -l threshold:,group:,ref:,remote:,diff:: -n "$PROGRAM" -- "$@")"
  426. eval set -- "$opts"
  427. while true; do case $1 in
  428. -t|--threshold) threshold="$2"; shift 2 ;;
  429. -g|--group) group="$2"; shift 2 ;;
  430. -r|--ref) ref="$2"; shift 2 ;;
  431. -o|--remote) remote="$2"; shift 2 ;;
  432. -d|--diff) diff="1"; shift 2 ;;
  433. --) shift; break ;;
  434. esac done
  435. local -r head=$(git rev-parse --short HEAD)
  436. if [ -n "$diff" ] && [ -z "$ref" ]; then
  437. while read -r commit; do
  438. echo "Checking commit: $commit"
  439. if verify "$threshold" "$group" "$commit"; then
  440. git --no-pager diff "${commit}" "${head}"
  441. return 0
  442. fi
  443. done <<< "$(git log --show-notes=signatures --pretty=format:"%H")"
  444. else
  445. if verify "$threshold" "$group" "$ref"; then
  446. if [ -n "$diff" ] && [ -n "$ref" ]; then
  447. local -r commit=$(git rev-parse --short "${ref}")
  448. [ "${commit}" != "${head}" ] && \
  449. git --no-pager diff "${commit}" "${head}"
  450. fi
  451. return 0
  452. fi
  453. fi
  454. return 1
  455. }
  456. cmd_add(){
  457. local opts method="" push="0"
  458. opts="$(getopt -o m:p:: -l method:,push:: -n "$PROGRAM" -- "$@")"
  459. eval set -- "$opts"
  460. while true; do case $1 in
  461. -m|--method) method="$2"; shift 2 ;;
  462. -p|--push) push="1"; shift 2 ;;
  463. --) shift; break ;;
  464. esac done
  465. case $method in
  466. note) sign_note "$push" ;;
  467. tag) sign_tag "$push" ;;
  468. *) sign_note "$push" ;;
  469. esac
  470. }
  471. cmd_push() {
  472. local opts remote="origin" push="0"
  473. opts="$(getopt -o r: -l remote: -n "$PROGRAM" -- "$@")"
  474. eval set -- "$opts"
  475. while true; do case $1 in
  476. -r|--remote) remote="$2"; shift 2 ;;
  477. --) shift; break ;;
  478. esac done
  479. git push --tags "$remote" refs/notes/signatures
  480. }
  481. cmd_pull() {
  482. local opts remote="origin"
  483. opts="$(getopt -o r: -l remote: -n "$PROGRAM" -- "$@")"
  484. eval set -- "$opts"
  485. while true; do case $1 in
  486. -r|--remote) remote="$2"; shift 2 ;;
  487. --) shift; break ;;
  488. esac done
  489. git fetch "$remote" refs/notes/signatures:refs/notes/${remote}/signatures
  490. git notes --ref signatures merge -s cat_sort_uniq "${remote}"/signatures
  491. }
  492. cmd_version() {
  493. cat <<-_EOF
  494. ==========================================
  495. = git-sig: multisig trust for git =
  496. = =
  497. = v0.4 =
  498. = =
  499. = https://codeberg.org/distrust/git-sig =
  500. ==========================================
  501. _EOF
  502. }
  503. cmd_usage() {
  504. cmd_version
  505. cat <<-_EOF
  506. Usage:
  507. git sig add [-m,--method=<note|tag>] [-p,--push]
  508. Add signature for this repository
  509. git sig remove
  510. Remove all signatures on current ref
  511. git sig verify [-g,--group=<group>] [-t,--threshold=<N>] [d,--diff=<branch>]
  512. Verify m-of-n signatures by given group are present for directory.
  513. git sig push [-r,--remote=<remote>]
  514. Push all signatures on current ref
  515. git sig pull [-r,--remote=<remote>]
  516. Pull all signatures for current ref
  517. git sig help
  518. Show this text.
  519. git sig version
  520. Show version information.
  521. _EOF
  522. }
  523. # Verify all tools in this list are installed at needed versions
  524. check_tools git head cut find sort sed getopt openssl ${GIT_SIG_VERIFY_COMMAND}
  525. # Allow entire script to be namespaced based on filename
  526. readonly PROGRAM="${0##*/}"
  527. # Export public sub-commands
  528. case "$1" in
  529. verify) shift; cmd_verify "$@" ;;
  530. add) shift; cmd_add "$@" ;;
  531. remove) shift; cmd_remove "$@" ;;
  532. push) shift; cmd_push "$@" ;;
  533. pull) shift; cmd_pull "$@" ;;
  534. version|--version) shift; cmd_version "$@" ;;
  535. help|--help) shift; cmd_usage "$@" ;;
  536. *) cmd_usage "$@" ;;
  537. esac