#!/bin/sh ############################################################################ # 'Litmus' Utility. Verifies traditional GPG RSA signatures using Peh. # # # # Usage: ./litmus.sh publickey.peh signature.sig datafile # # # # Currently, supports RSA 'clearsigned' and 'detached' sigs made with the # # following hashes: # # SHA1 (warns: known-breakable!), SHA224, SHA256, SHA384, SHA512. # # # # See instructions re: converting traditional GPG public keys for use with # # this program. # # # # Peh, xxd, hexdump, shasum, and a number of common utils (see EXTERNALS) # # must be present on your machine. # # # # (C) 2020 Stanislav Datskovskiy ( www.loper-os.org ) # # http://wot.deedbot.org/17215D118B7239507FAFED98B98228A001ABFFC7.html # # # # You do not have, nor can you ever acquire the right to use, copy or # # distribute this software ; Should you use this software for any purpose, # # or copy and distribute it to anyone or in any manner, you are breaking # # the laws of whatever soi-disant jurisdiction, and you promise to # # continue doing so for the indefinite future. In any case, please # # always : read and understand any software ; verify any PGP signatures # # that you use - for any purpose. # ############################################################################ # External programs that are required (if not found, will eggog) : EXTERNALS="peh xxd hexdump base64 shasum cut tr sed wc grep printf mktemp awk truncate" # Return Codes: # Signature is VALID for given Sig, Data File, and Public Key: RET_VALID_SIG=0 # Signature is INVALID: RET_BAD_SIG=1 # All Other Cases: RET_EGGOG=-1 # Terminations: # Success (Valid RSA signature) : done_sig_valid() { echo "VALID $pubkey_algo signature from $pubkey_owner" exit $RET_VALID_SIG } # Failure (INVALID RSA signature) : done_sig_bad() { echo "Signature is INVALID for this public key and input file!" exit $RET_BAD_SIG } # Failure in decoding 'GPG ASCII armour' : eggog_sig_armour() { echo "$SIGFILE could not decode as a GPG ASCII-Armoured Signature!" >&2 exit $RET_EGGOG } # Failure from corrupt signature : eggog_sig_corrupt() { echo "$SIGFILE is corrupt!" >&2 exit $RET_EGGOG } # If Sig was made with an unsupported hash algo: eggog_unsupported_hash() { algo=$1 echo "This sig uses an unsupported Digest Algo: $1 !" >&2 exit $RET_EGGOG } # Malformed 'clearsigned' text file: eggog_broken_clearsigned() { echo "$SIGFILE does not contain a clearsigned PGP message!" >&2 exit $RET_EGGOG } # Failure from bad Peh : eggog_peh() { echo "EGGOG in executing Peh tape! Please check Public Key." >&2 exit $RET_EGGOG } # Warnings: achtung() { echo "WARNING: $1" >&2 } # First argument is always the given public key file (a Peh tape, see docs) PUBFILE=$1 # The given Detached GPG Signature file to be verified. # In 'clearsigned' mode, contains both signature and payload to be verified. SIGFILE=$2 # Get total number of arguments on command line: ARGCOUNT="$#" # On exit (if in 'clearsign' mode) : remove_temp_file() { rm -f $DATAFILE } # Whether we are working on a 'clearsigned text' CLEARSIGN_MODE=false # Set up in the selected mode: case $ARGCOUNT in 2) # If given two arguments, verify a 'clearsigned' text file: CLEARSIGN_MODE=true # The processed payload will end up in a temporary file: DATAFILE=$(mktemp) || { echo "Failed to create temp file!" >&2; \ exit $RET_EGGOG; } # On exit, if in 'clearsign' mode, remove temporary file with payload: trap remove_temp_file EXIT # Expect 'Canonical Text Signature' in GPG sig packet turd expect_sig_class=1 ;; 3) # Verify Detached Signature on given Data File (third argument is path): # The given Data file to be verified against the Signature DATAFILE=$3 # i.e. path given on command line # Expect 'Detached Binary Signature' in GPG sig packet turd expect_sig_class=0 ;; *) # If invalid arg count -- print usage and abort: echo "Usage: $0 publickey.peh signature.sig datafile" echo " or: $0 publickey.peh clearsigned.txt" exit $RET_EGGOG ;; esac # Minimal Peh Width (used for non-arithmetical ops, e.g. 'Owner') MIN_PEH_WIDTH=256 # Peh data stack height for public key operations PEH_HEIGHT=3 # Peh RNG (NOT USED in verifications, but needed to silence warning) PEH_RNG_DEV="/dev/random" # Verify that each of the given input files exists: FILES=($PUBFILE $SIGFILE $DATAFILE) for f in ${FILES[@]}; do if ! [ -f $f ]; then echo "$f does not exist!" >&2 exit $RET_EGGOG fi done # Calculate length of the pubkey file: PUBFILE_LEN=$(wc -c $PUBFILE | cut -d ' ' -f1) # Peh's Return Codes PEH_YES=1 PEH_NO=0 PEH_MU=255 PEH_EGGOG=254 # Execute given Peh tape, with given FFA Width and Height, # on top of the pubkey tape; returns output in $peh_res and $peh_code. run_peh_tape() { # The tape itself tape=$1 # FFA Width for the tape peh_width=$2 # FFA Stack Height for the tape peh_height=$3 # Compute the length of the given tape tape_len=${#tape} # Add the length of the Public Key tape to the above tape_len=$(($tape_len + $PUBFILE_LEN)) # Max Peh Life for all such tapes peh_life=$(($tape_len * 2)) # Execute the tape: peh_res=$((cat $PUBFILE; echo $tape) | \ peh $peh_width $peh_height $tape_len $peh_life $PEH_RNG_DEV); peh_code=$? # # If Peh returned PEH_EGGOG: if [ $peh_code -eq $PEH_EGGOG ] then # Abort: likely, coarse error of pilotage in the public key tape. eggog_peh fi } # Ask the public key about Algo Type: run_peh_tape "@Algo!QY" $MIN_PEH_WIDTH 1 pubkey_algo=$peh_res # Ask the public key about the Owner: run_peh_tape "@Owner!QY" $MIN_PEH_WIDTH 1 pubkey_owner=$peh_res # The only supported algo is GPG RSA: if [ "$pubkey_algo" != "GPG RSA" ] then echo "This public key specifies algo '$pubkey_algo';" >&2 echo "The only algo supported is 'GPG RSA' !" >&2 exit $RET_EGGOG fi # Verify that all of the necessary external programs in fact exist: for i in $EXTERNALS do command -v $i >/dev/null && continue || \ { echo "$i is required but was not found! Please install it." >&2 ; \ exit $RET_EGGOG; } done # 'ASCII-Armoured' PGP signatures have mandatory start and end markers: START_MARKER="^\-\-\-\-\-BEGIN PGP SIGNATURE\-\-\-\-\-" END_MARKER="^\-\-\-\-\-END PGP SIGNATURE\-\-\-\-\-" # Determine start and end line positions for payload: start_ln=$(grep -m 1 -n "$START_MARKER" $SIGFILE | cut -d ':' -f1) end_ln=$(grep -m 1 -n "$END_MARKER" $SIGFILE | cut -d ':' -f1) # Both start and end markers must exist : if [ "$start_ln" == "" ] || [ "$end_ln" == "" ] then echo "$SIGFILE does not contain ASCII-armoured PGP Signature!" >&2 exit $RET_EGGOG fi # Discard the markers: start_ln=$(($start_ln + 1)) end_ln=$(($end_ln - 1)) # If there is no payload, or the markers are misplaced, abort: if [ $start_ln -ge $end_ln ] then eggog_sig_armour fi # Extract sig payload: sig_payload=$(sed -n "$start_ln,$end_ln p" < $SIGFILE | \ sed -n "/^Version/!p" | sed -n "/^=/!p" | tr -d " \t\n\r") # If eggog -- abort: if [ $? -ne 0 ] then eggog_sig_armour fi # Obtain the sig bytes: sig_bytes=($(echo $sig_payload | base64 -d | hexdump -ve '1/1 "%.2x "')) # If eggog -- abort: if [ $? -ne 0 ] then eggog_sig_armour fi # If we are operating on a 'clearsigned' text file, $DATAFILE will be # an empty temporary file, and the payload is to be extracted to it, # with certain munges (see http://tools.ietf.org/html/rfc4880#section-7.1) if [ $CLEARSIGN_MODE == true ] then # Find position of 'clearsign' payload start marker: CLEAR_MARKER="^\-\-\-\-\-BEGIN PGP SIGNED MESSAGE\-\-\-\-\-" start_clr=$(grep -m 1 -n "$CLEAR_MARKER" $SIGFILE | cut -d ':' -f1) # If payload start marker was not found: if [ "$start_clr" == "" ] then eggog_broken_clearsigned fi # Discard the start marker: start_clr=$(($start_clr + 1)) # The payload ends with the line preceding the sig start: end_clr=$((start_ln - 2)) # Find any 'Hash:' headers: start_body=$(tail -n "+$start_clr" $SIGFILE | \ grep -v -n -m 1 "^Hash:" | cut -d ':' -f1) # Skip the above headers and mandatory empty line: start_clr=$(($start_clr + $start_body)) # If there is no payload, or the markers are misplaced, abort: if [ $start_clr -ge $end_clr ] then eggog_broken_clearsigned fi # Extract the 'clearsign' payload to the temporary file: cat $SIGFILE | sed -n "$start_clr,$end_clr p" | \ sed 's/[ \t]*$//; s/^- //' | \ awk '{printf("%s\r\n",$0)}' \ > $DATAFILE # Remove the trailing CR,LF ending: truncate -s -2 $DATAFILE # After this, proceed exactly like with 'detached' sigs, but # with the expected 'class' being 1 rather than 0. fi # Number of bytes in the sig file sig_len=${#sig_bytes[@]} # Test that certain fields in the Sig have their mandatory value sig_field_mandatory() { f_name=$1 f_value=$2 f_mandate=$3 if [ "$f_value" != "$f_mandate" ] then reason="$f_name must equal $f_mandate; instead is $f_value." echo "$SIGFILE is UNSUPPORTED : $reason" >&2 exit $RET_EGGOG fi } # Starting Position for get_sig_bytes() sig_pos=0 # Extract given # of sig bytes from the current sig_pos; advance sig_pos. get_sig_bytes() { # Number of bytes requested count=$1 # Result: $count bytes from current $sig_pos (contiguous hex string) r=$(echo ${sig_bytes[@]:$sig_pos:$count} | sed "s/ //g" | tr 'a-z' 'A-Z') # Advance $sig_pos by $count: sig_pos=$(($sig_pos + $count)) # If more bytes were requested than were available in sig_bytes: if [ $sig_pos -gt $sig_len ] then # Abort. The signature was mutilated somehow. eggog_sig_corrupt fi } # Convert the current sig component to integer hex_to_int() { r=$((16#$r)) } # Turd to be composed of certain values from the sig, per RFC4880. # Final hash will run on the concatenation of DATAFILE and this turd. turd="" ## Parse all of the necessary fields in the GPG Signature: # CTB (must equal 0x89) get_sig_bytes 1 sig_ctb=$r sig_field_mandatory "Version" $sig_ctb 89 # Length get_sig_bytes 2 hex_to_int sig_length=$r # Version (only Version 4 -- what GPG 1.4.x outputs -- is supported) get_sig_bytes 1 turd+=$r sig_version=$r sig_field_mandatory "Version" $sig_version 04 # Class (must be 'detached' or 'clearsign') get_sig_bytes 1 turd+=$r hex_to_int sig_class=$r sig_field_mandatory "Class" $sig_class $expect_sig_class # Public Key Algo (only RSA is supported) get_sig_bytes 1 turd+=$r sig_pk_algo=$r sig_field_mandatory "Public Key Algo" $sig_pk_algo 01 # Digest Algo (only certain hash algos are supported) get_sig_bytes 1 turd+=$r hex_to_int sig_digest_algo=$r # If hash algo is supported, get ASN turd and MD_LEN; and if not, eggog: case $sig_digest_algo in 1) ## MD5 -- NOT SUPPORTED ## eggog_unsupported_hash "MD5" ;; 2) ## SHA1 ## achtung "This sig was made with SHA-1, which is cheaply breakable!" achtung "Please contact the signer ($pubkey_owner) !" HASHER="shasum -a 1 -b" ASN="3021300906052b0e03021a05000414" MD_LEN=20 ;; 3) ## RIPE-MD/160 -- NOT SUPPORTED ## eggog_unsupported_hash "RIPE-MD/160" ;; 8) ## SHA256 ## achtung "This sig was made with SHA-256; GPG supports SHA-512." achtung "Please contact the signer ($pubkey_owner) !" HASHER="shasum -a 256 -b" ASN="3031300d060960864801650304020105000420" MD_LEN=32 ;; 9) ## SHA384 ## achtung "This sig was made with SHA-384; GPG supports SHA-512." achtung "Please contact the signer ($pubkey_owner) !" HASHER="shasum -a 384 -b" ASN="3041300d060960864801650304020205000430" MD_LEN=48 ;; 10) ## SHA512 ## HASHER="shasum -a 512 -b" ASN="3051300D060960864801650304020305000440" MD_LEN=64 # 512 / 8 == 64 bytes ;; 11) ## SHA224 ## achtung "This sig was made with SHA-224; GPG supports SHA-512." achtung "Please contact the signer ($pubkey_owner) !" HASHER="shasum -a 224 -b" ASN="302D300d06096086480165030402040500041C" MD_LEN=28 ;; *) ## Unknown Digest Type ## eggog_unsupported_hash "UNKNOWN (type $sig_digest_algo)" ;; esac # Calculate length (bytes) of the ASN turd for the digest used in the sig: ASN_LEN=$((${#ASN} / 2)) # Hashed Section Length get_sig_bytes 2 turd+=$r hex_to_int sig_hashed_len=$r # Hashed Section (typically: timestamp) get_sig_bytes $sig_hashed_len turd+=$r sig_hashed=$r # Unhashed Section Length get_sig_bytes 1 hex_to_int sig_unhashed_len=$r # Unhashed Section (discard) get_sig_bytes $sig_unhashed_len # Compute Byte Length of Hashed Header (for last field) hashed_header_len=$((${#turd} / 2)) # Final section of the hashed turd (not counted in hashed_header_len) turd+=$sig_version turd+="FF" turd+=$(printf "%08x" $hashed_header_len) # Compute the hash of data file and the hashed appendix from sig : hash=$((cat $DATAFILE; xxd -r -p <<< $turd) | $HASHER | cut -d ' ' -f1) # Convert to upper case hash=$(echo $hash | tr 'a-z' 'A-Z') # Parse the RSA Signature portion of the Sig file: # RSA Packet Length (how many bytes to read) get_sig_bytes 1 hex_to_int rsa_packet_len=$r # The RSA Packet itself get_sig_bytes $rsa_packet_len rsa_packet=$r # Digest Prefix (2 bytes) get_sig_bytes 2 digest_prefix=$r # See whether it matches the first two bytes of the actual computed hash : computed_prefix=$(printf "%.4s" $hash) if [ "$digest_prefix" != "$computed_prefix" ] then # It didn't match, so we can return 'bad signature' immediately: done_sig_bad fi # If prefix matched, we will proceed to do the actual RSA operation. # RSA Bitness given in Sig get_sig_bytes 2 hex_to_int rsa_bitness=$r # Compute RSA Byteness from the above rsa_byteness=$((($rsa_bitness + 7) / 8)) # RSA Bitness for use in determining required Peh width: rsa_width=$(($rsa_byteness * 8)) # Only traditional GPG RSA widths are supported: if [ $rsa_width != 2048 ] && [ $rsa_width != 4096 ] && [ $rsa_width != 8192 ] then reason="Only 2048, 4096, and 8192-bit RSA are supported." echo "$SIGFILE is UNSUPPORTED : $reason" >&2 exit $RET_EGGOG fi # RSA Signature per se (final item read from sig file) get_sig_bytes $rsa_byteness rsa_sig=$r # Per RFC4880, 'PKCS' encoding of hash is as follows: # 0 1 [PAD] 0 [ASN] [MD] # First two bytes of PKCS-encoded hash will always be 00 01 : pkcs="0001" # Compute necessary number of padding FF bytes : pkcs_pad_bytes=$(($rsa_byteness - $MD_LEN - $ASN_LEN - 3)) # Attach the padding bytes: for ((x=1; x<=$pkcs_pad_bytes; x++)); do pkcs+="FF" done # Attach the 00 separator between the padding and the ASN: pkcs+="00" # Attach the ASN ('magic' corresponding to the hash algo) : pkcs+=$ASN # Finally, attach the computed (from Data file) hash itself : pkcs+=$hash # Generate a Peh tape which will attempt to verify $rsa_sig against the pubkey, # computing the expression $rsa_sig ^ PUB_E mod PUB_M and comparing to $pkcs. # Outputs 'Valid' and returns Yes_Code (1) if and only if signature is valid. tape=".$rsa_sig@Public-Op!.$pkcs={[Valid]QY}{[Invalid]QN}_" # Execute the tape: run_peh_tape $tape $rsa_width $PEH_HEIGHT # 'Belt and suspenders' -- test both output and return code: # If verification succeeded, return code will be 1, and output 'Valid': if [ $peh_code -eq $PEH_YES ] && [ "$peh_res" == "Valid" ] then # Valid RSA signature: done_sig_valid else # Signature was not valid: done_sig_bad fi # The end.