|
| 1 | +#!/bin/bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +CHECKINPUT=0 |
| 5 | +SCRIPTFOLDER="$(dirname "$(readlink -f "$0")")" |
| 6 | + |
| 7 | +if [ $# -lt 3 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then |
| 8 | + echo "Usage: $0 PUBKEY PAYLOAD OUTPUT [KERNELOUTPUT]" |
| 9 | + echo "Decodes a payload, writing the decoded payload to stdout and payload information to stderr" |
| 10 | + echo "Only one optional kernel payload is supported (the output file will be zero-sized if no payload was found)." |
| 11 | + echo "Only a signle signature is supported as only one pubkey is accepted and in the multi-signature case only the second signature is looked at." |
| 12 | + echo "Pass CHECKINPUT=1 to process the inline checksums in addition to the final checksum." |
| 13 | + exit 1 |
| 14 | +fi |
| 15 | + |
| 16 | +PUBKEY="$1" |
| 17 | +FILE="$2" |
| 18 | +PARTOUT="$3" |
| 19 | +KERNEL="${4-}" |
| 20 | + |
| 21 | +# The format is documented in src/update_engine/update_metadata.proto |
| 22 | +# The header itself is not a protobuf and we can extract the manifest protobuf size as big endian uint64 from offset 12 and convert it to a decimal string |
| 23 | +MLEN=$(dd status=none bs=1 skip=12 count=8 if="${FILE}" | od --endian=big -An -vtu8 -w1024 | tr -d ' ') |
| 24 | + |
| 25 | +# The manifest starts at offset 20 (with tail we do a +1 compared to dd) and we feed it into protoc for decoding (we assume that the text output format is stable) |
| 26 | +DESC=$(protoc --decode=chromeos_update_engine.DeltaArchiveManifest --proto_path "${SCRIPTFOLDER}"/src/update_engine "${SCRIPTFOLDER}"/src/update_engine/update_metadata.proto < <({ tail -c +21 "${FILE}" || true ; } |head -c "${MLEN}")) |
| 27 | + |
| 28 | +echo "${DESC}" >&2 |
| 29 | + |
| 30 | +# Truncate |
| 31 | +true > "${PARTOUT}" |
| 32 | +if [ "${KERNEL}" != "" ]; then |
| 33 | + true > "${KERNEL}" |
| 34 | +fi |
| 35 | + |
| 36 | +PARALLEL=$(nproc) |
| 37 | +RUNNING=0 |
| 38 | +# MODE is the current parsing context, OUT is either PARTOUT or KERNEL |
| 39 | +# A new context/mode should reset the state variables it expects to get set (e.g., DATAHASH, or SIZE and HASH) |
| 40 | +# FINALSIZE/-HASH and KERNELSIZE/-HASH store the section values for later |
| 41 | +MODE="" OUT="" CAT="cat" OFFSET=0 LENGTH=0 START=0 NUM_BLOCKS=0 DATAHASH="" HASH="" SIZE=0 FINALSIZE=0 FINALHASH="" KERNELSIZE=0 KERNELHASH="" SIGOFFSET=0 SIGSIZE=0 |
| 42 | +while IFS= read -r LINE; do |
| 43 | + LINE=$(echo "${LINE}" | sed 's/^ *//g') |
| 44 | + case "${LINE}" in |
| 45 | + "partition_operations {") MODE="partition_operations" DATAHASH="" OUT="${PARTOUT}" ;; # Each of these sections has a part of the split payload |
| 46 | + "type: REPLACE_BZ") CAT="bzcat" ;; # The payload part is compressed if it reduces the size |
| 47 | + "type: REPLACE") CAT="cat" ;; |
| 48 | + "data_offset:"*) OFFSET=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; # Read pointers into the payload, in bytes |
| 49 | + "data_length:"*) LENGTH=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; |
| 50 | + "start_block:"*) START=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; # Part of dst_extents, write pointer for the target, in blocks |
| 51 | + "data_sha256_hash:"*) DATAHASH=$(echo "${LINE}" | cut -d '"' -f 2- | head -c-2 | sed 's/%/%%/g') ;; # Expected to be the last entry, |
| 52 | + # almost printf-able but we have to escape % and truncate the closing quote because we can't split by quotes as it may contain them |
| 53 | + "num_blocks:"*) NUM_BLOCKS=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; # Part of dst_extents, not used because dd doesn't need it |
| 54 | + "block_size:"*) ;; # Comes after partition_operations, assumed to be 4096, not checked |
| 55 | + "signatures_offset:"*) SIGOFFSET=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; |
| 56 | + "signatures_size:"*) SIGSIZE=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; |
| 57 | + "dst_extents {") ;; # Part of partition_operations or operations |
| 58 | + "noop_operations {") MODE="noop_operations" DATAHASH="" OUT="" ;; # Comes after partition_operations, ignored |
| 59 | + "new_partition_info {") MODE="new_partition_info" SIZE=0 HASH="" OUT="" ;; |
| 60 | + "hash:"*) HASH=$(echo "${LINE}" | cut -d '"' -f 2- | head -c-2 | sed 's/%/%%/g') ;; # Part of new_partition_info |
| 61 | + "size:"*) SIZE=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; |
| 62 | + "new_info {") MODE="new_info" SIZE=0 HASH="" OUT="" ;; |
| 63 | + "procedures {") MODE="procedures" DATAHASH="" OUT="" ;; # Note that only one kernel is supported and nothing else |
| 64 | + "operations {") MODE="operations" DATAHASH="" ;; # Nested in "procedures", each of these sections has a part of the split payload |
| 65 | + "type: KERNEL") if [ "${KERNEL}" != "" ]; then OUT="${KERNEL}" ; fi ;; |
| 66 | + "}") ;; |
| 67 | + *) echo "unmatched: $LINE" >&2 ;; |
| 68 | + esac |
| 69 | + if [ "${OUT}" != "" ] && [ "${DATAHASH}" != "" ]; then |
| 70 | + ( |
| 71 | + # The payload data split in parts starts after the head and the manifest, and from there we have the variable offset for each part |
| 72 | + { { tail -c +$((21 + MLEN + OFFSET)) "${FILE}" || true ; } | head -c "${LENGTH}" | "${CAT}" | dd status=none bs=4096 seek="${START}" of="${OUT}" ; } || { echo "Write error" >&2 ; exit 1 ; } |
| 73 | + # Each part has a checksum but since we also have one at the end it's not really that meaningful to check, so only done conditionally |
| 74 | + if [ "${CHECKINPUT}" = 1 ] && [ "$({ tail -c +$((21 + MLEN + OFFSET)) "${FILE}" || true ; } | head -c "${LENGTH}" | sha256sum | cut -d ' ' -f 1)" != "$(printf -- "${DATAHASH}" | od -An -vtx1 -w1024 | tr -d ' ')" ]; then |
| 75 | + echo "Data hash mismatch" >&2 |
| 76 | + exit 1 # Script will continue and fail at the end |
| 77 | + fi |
| 78 | + ) & |
| 79 | + DATAHASH="" |
| 80 | + RUNNING=$((RUNNING + 1)) |
| 81 | + fi |
| 82 | + if [ "${RUNNING}" = "${PARALLEL}" ]; then |
| 83 | + wait |
| 84 | + RUNNING=0 |
| 85 | + fi |
| 86 | + if [ "${MODE}" = "new_partition_info" ] && [ "${SIZE}" != 0 ]; then |
| 87 | + FINALSIZE="${SIZE}" |
| 88 | + fi |
| 89 | + if [ "${MODE}" = "new_partition_info" ] && [ "${HASH}" != "" ]; then |
| 90 | + FINALHASH="${HASH}" |
| 91 | + fi |
| 92 | + if [ "${MODE}" = "new_info" ] && [ "${SIZE}" != 0 ]; then |
| 93 | + KERNELSIZE="${SIZE}" |
| 94 | + fi |
| 95 | + if [ "${MODE}" = "new_info" ] && [ "${HASH}" != "" ]; then |
| 96 | + KERNELHASH="${HASH}" |
| 97 | + fi |
| 98 | +done <<< "${DESC}" |
| 99 | +wait |
| 100 | + |
| 101 | +if [ "$(stat '-c%s' "${PARTOUT}")" != "${FINALSIZE}" ]; then |
| 102 | + echo "Size mismatch" >&2 |
| 103 | + exit 1 |
| 104 | +fi |
| 105 | +if [ "$(printf -- "${FINALHASH}" | od -An -vtx1 -w1024 | tr -d ' ')" != "$(sha256sum "${PARTOUT}" | cut -d ' ' -f 1)" ]; then |
| 106 | + echo "Hash mismatch" >&2 |
| 107 | + exit 1 |
| 108 | +fi |
| 109 | +if [ "${KERNEL}" != "" ] && [ "$(stat '-c%s' "${KERNEL}")" != "${KERNELSIZE}" ]; then |
| 110 | + echo "Kernel size mismatch" >&2 |
| 111 | + exit 1 |
| 112 | +fi |
| 113 | +if [ "${KERNEL}" != "" ] && [ "$(printf -- "${KERNELHASH}" | od -An -vtx1 -w1024 | tr -d ' ')" != "$(sha256sum "${KERNEL}" | cut -d ' ' -f 1)" ]; then |
| 114 | + echo "Kernel hash mismatch" >&2 |
| 115 | + exit 1 |
| 116 | +fi |
| 117 | + |
| 118 | +# The signature protobuf message is at the signature offset |
| 119 | +# Decoding the "data" field caues some troubles and needs a workaround below for the dev key |
| 120 | +SIGDESC=$(protoc --decode=chromeos_update_engine.Signatures --proto_path "${SCRIPTFOLDER}"/src/update_engine "${SCRIPTFOLDER}"/src/update_engine/update_metadata.proto < <({ tail -c +$((21 + MLEN + SIGOFFSET)) "${FILE}" || true ; } |head -c "${SIGSIZE}")) |
| 121 | +echo "${SIGDESC}" >&2 |
| 122 | +VERSION=2 # Init for the single-signature case, in the many-signature case the first signature ("version 1" but better would be "number 1") is, |
| 123 | +# at least for Flatcar production, a dummy and parsing it overwrites this variable with 1 and it will be ignored, |
| 124 | +# the second signature is "version 2" and the one we want to check. Even if only one signature is there it becomes "version 2", |
| 125 | +# see https://github.com/flatcar/update_engine/blob/c6f566d47d8949632f7f43871eb8d5c625af3209/src/update_engine/payload_signer.cc#L33 |
| 126 | +while IFS= read -r LINE; do |
| 127 | + LINE=$(echo "${LINE}" | sed 's/^ *//g') |
| 128 | + case "${LINE}" in |
| 129 | + "version:"*) VERSION=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; |
| 130 | + "data:"*) |
| 131 | + SIGDATA=$(echo "${LINE}" | cut -d '"' -f 2- | head -c-2 | sed 's/%/%%/g') |
| 132 | + # This is a workaround for the dev-key vs prod-key case: sed '/signatures {/d' | sed '/ version: 2/d' |
| 133 | + SIGHEX=$(printf -- "${SIGDATA}" | sed '/signatures {/d' | sed '/ version: 2/d' | openssl rsautl -verify -pubin -inkey "${PUBKEY}" -raw | tail -c 32 | od -An -vtx1 -w1024 | tr -d ' ') |
| 134 | + # The raw output instead of asn1parse is used to easily extract the sha256 checksum (done by tail -c 32) |
| 135 | + # We also calculate the payload hash that the signature was done for, note that it's of course not the whole file but only up to the attached signature itself |
| 136 | + PAYLOADHASH=$(head -c "$((20 + MLEN + SIGOFFSET))" "${FILE}" | sha256sum | cut -d ' ' -f 1) |
| 137 | + if [ "${VERSION}" = 2 ] && [ "${SIGHEX}" != "${PAYLOADHASH}" ]; then |
| 138 | + echo "Signature error" >&2 |
| 139 | + exit 1 |
| 140 | + elif [ "${VERSION}" != 2 ]; then |
| 141 | + # For Flatcar production this is a dummy signature with a random key, |
| 142 | + # see https://github.com/flatcar/flatcar-build-scripts/blob/821d8da19567e3d1a29dc24f8c822f67df6a5e02/generate_payload#L384 |
| 143 | + echo "Unprocessed 'version ${VERSION}' signature (Payload hash: ${PAYLOADHASH}, SIGDATA: ${SIGDATA})" >&2 |
| 144 | + fi |
| 145 | + ;; |
| 146 | + *) ;; |
| 147 | + esac |
| 148 | +done <<< "${SIGDESC}" |
| 149 | + |
| 150 | +echo "Success" >&2 |
0 commit comments