필사 모드: Air-Gap Transfer Procedure and Integrity — Building a Bundle That Still Installs the Same Way Six Months Later
English- Opening — why signature verification matters more in an air-gapped network
- What goes on the medium
- Verify the signatures
- A reproducible bundle — building the manifest
- Compare after transfer
- What to record in the chain of custody
- Closing — the medium has to stand on its own
- Try it yourself
- Previous / next in the series
- References
Opening — why signature verification matters more in an air-gapped network
On a connected network, if something looks wrong you just download it again. If the hash does not match you compare against the original; if the signature looks off you ask the distributor. The thing you compare against is always within reach.
An air-gapped network breaks that premise. The moment the medium goes inside, the way to compare against the original is gone. Six months later, when someone asks "where did this rpm come from", if the evidence that answers the question was not carried in on the same medium, you can never answer it.
So in an air-gapped network, integrity verification is an operational requirement before it is a security requirement. This post is about putting that evidence inside the bundle.
What goes on the medium
A transfer unit contains more than rpm files. At minimum, these six things.
- The rpm file set — what you built in part 2
- repodata — the repository metadata generated in part 3
- The GPG public key — required to verify signatures on the inside
- The manifest — what is inside, and from which point in time
- The checksum file — separate from the manifest, for checking the integrity of the medium
- The install procedure — what to run, and in what order
Leave out item 3 and gpgcheck=1 blocks the very first install on the inside. Leave out item 4 and reproduction becomes impossible. In practice these two are the ones most often forgotten.
Verify the signatures
An rpm signature check verifies the digests and the signatures of a package together. The current upstream man pages put this function in rpmkeys and classify rpm's -K, --checksig, and --import as "Obsolete compatibility aliases". Both spellings work on RHEL 8, 9, and 10, but for a new procedure document you are better off using the rpmkeys form.
# Signature verification only means something once the key has been imported
sudo rpmkeys --import /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release
# Check the signatures and digests of the entire bundle
rpmkeys --checksig /var/tmp/airgap-bundle/rpms/*.rpm
# Show only the failures
rpmkeys --checksig /var/tmp/airgap-bundle/rpms/*.rpm | grep -v 'digests signatures OK'
The rpmkeys man page defines -K, --checksig as "Verify the digests and signatures contained in PACKAGE_FILE to ensure the integrity and origin of the package". The second clause is the point. The digest guarantees integrity, the signature guarantees origin. They answer different questions, and you need both.
If you run the check without importing the key, the digest passes and the signature comes back as unverifiable. Reading that state as a "pass" is a common mistake, so before checking, use rpmkeys --list to confirm the key is actually imported.
You can also filter at the download stage. dnf reposync's -g, --gpgcheck from part 2 is documented as "Remove packages that fail GPG signature checking after downloading. Exit code is 1 if at least one package was removed", so with that option a package with a broken signature never enters the bundle in the first place, and the exit code tells you it happened.
A reproducible bundle — building the manifest
This is the heart of this post.
A bundle being reproducible means that with the same manifest in hand you can get the same install result six months later. For that, the manifest needs at least five things.
- The complete NEVRA — name, epoch, version, release, architecture
- A per-package checksum — SHA-256
- The repository snapshot date — when it was downloaded
- The release version used — what was passed to
--releasever - The source repository ID — which repository it came from
There is a reason to go out of your way to include the epoch. The RPM tags reference defines Epoch as "Package epoch (optional). An absent epoch is equal to epoch value 0", and the file name does not carry the epoch. So a list built from file names alone cannot tell two packages with different epochs apart. Since the epoch takes the highest precedence in version comparison, that difference is both quiet and fatal.
You extract the manifest in a single pass with an rpm query.
#!/usr/bin/env bash
# make-manifest.sh — generate a reproducible manifest from a bundle directory
set -euo pipefail
BUNDLE_DIR="${1:?usage: make-manifest.sh <bundle-dir>}"
RELEASEVER="${2:?usage: make-manifest.sh <bundle-dir> <releasever>}"
SNAPSHOT_DATE="$(date -u +%Y-%m-%d)"
OUT="${BUNDLE_DIR}/MANIFEST.tsv"
{
echo "# bundle_id: rhel-airgap-${SNAPSHOT_DATE}"
echo "# snapshot_date: ${SNAPSHOT_DATE}"
echo "# releasever: ${RELEASEVER}"
echo "# source_repos: rhel-9-for-x86_64-baseos-rpms,rhel-9-for-x86_64-appstream-rpms"
echo "# generated_by: $(rpm --version)"
printf 'NAME\tEPOCH\tVERSION\tRELEASE\tARCH\tSHA256\tFILE\n'
} > "${OUT}"
for f in "${BUNDLE_DIR}"/rpms/*.rpm; do
# For a package with no epoch, the ternary fills in 0
nevra="$(rpm -qp --queryformat \
'%{NAME}\t%|EPOCH?{%{EPOCH}}:{0}|\t%{VERSION}\t%{RELEASE}\t%{ARCH}' \
"$f" 2>/dev/null)"
sum="$(sha256sum "$f" | cut -d' ' -f1)"
printf '%s\t%s\t%s\n' "${nevra}" "${sum}" "$(basename "$f")"
done >> "${OUT}"
echo "wrote ${OUT} ($(grep -cv '^#' "${OUT}") lines)"
The rpm man page defines -p, --package as "Query an (uninstalled) package PACKAGE_FILE" and --queryformat as "Output format of each queried package, as described by rpm-queryformat(7)". The ternary notation comes from the rpm query format documentation and specifies the value to use when a tag is absent. You can use it exactly as-is to fill in 0 for packages whose epoch is empty.
The resulting file looks like this.
# bundle_id: rhel-airgap-2026-08-15
# snapshot_date: 2026-08-15
# releasever: 9.4
# source_repos: rhel-9-for-x86_64-baseos-rpms,rhel-9-for-x86_64-appstream-rpms
# generated_by: RPM version 4.16.1.3
NAME EPOCH VERSION RELEASE ARCH SHA256 FILE
httpd 0 2.4.57 11.el9_4 x86_64 3f1c... httpd-2.4.57-11.el9_4.x86_64.rpm
mod_ssl 1 2.4.57 11.el9_4 x86_64 9ab2... mod_ssl-2.4.57-11.el9_4.x86_64.rpm
Look at the epoch on the mod_ssl line: it is 1. That number appears nowhere in the file name.
Compare after transfer
Creating the manifest is not where the job ends. You have to compare against it right after the bundle arrives on the inside.
#!/usr/bin/env bash
# verify-bundle.sh — compare the manifest against the actual bundle
set -euo pipefail
BUNDLE_DIR="${1:?usage: verify-bundle.sh <bundle-dir>}"
MANIFEST="${BUNDLE_DIR}/MANIFEST.tsv"
ERR=0
echo "== bundle metadata"
grep '^#' "${MANIFEST}"
echo "== checksum comparison"
while IFS=$'\t' read -r name epoch version release arch sha file; do
path="${BUNDLE_DIR}/rpms/${file}"
if [ ! -f "${path}" ]; then
echo "MISSING ${file}"; ERR=$((ERR+1)); continue
fi
actual="$(sha256sum "${path}" | cut -d' ' -f1)"
if [ "${actual}" != "${sha}" ]; then
echo "MISMATCH ${file}"; ERR=$((ERR+1))
fi
done < <(grep -v '^#' "${MANIFEST}" | tail -n +2)
echo "== files present but not in the manifest"
comm -13 \
<(grep -v '^#' "${MANIFEST}" | tail -n +2 | cut -f7 | sort) \
<(cd "${BUNDLE_DIR}/rpms" && ls -1 *.rpm | sort)
echo "== signature verification"
rpmkeys --checksig "${BUNDLE_DIR}"/rpms/*.rpm \
| grep -v 'digests signatures OK' || true
echo "== result: ${ERR} error(s)"
exit "${ERR}"
There is a reason the "files not in the manifest" check is in there. A missing file shows up quickly as an install failure, but a file that sits on the medium without being on the list slips past unnoticed. From the point of view of a transfer review board, that one is the bigger problem.
Shipping the checksum file on the medium also deserves a word. A checksum stored only on the same medium changes right along with it if the whole medium is swapped. The real guarantee comes from the signature. The checksum is for detecting corruption in transit; the guarantee of origin is what the GPG signature provides. That is why the script above checks both.
What to record in the chain of custody
Audit response and reproduction are solved by the same record. This much per bundle is enough.
| Item | Example | Why it is needed |
|---|---|---|
| Bundle ID | rhel-airgap-2026-08-15 | The key that ties a server to a bundle |
| Snapshot date | 2026-08-15 | Which point in time the content is from |
| releasever | 9.4 | The pinned minor version (part 5) |
| Source repository ID | rhel-9-for-x86_64-baseos-rpms | The input when you rebuild it |
| Creation command | dnf download --installroot=... --releasever=9.4 ... | The heart of reproduction |
| Medium hash | SHA-256 of the medium image | Integrity at the level of the medium |
| Transfer date and owner | — | Audit requirement |
| Target servers | — | Tracking the blast radius |
The row telling you to record the whole creation command is the important one. When you have to rebuild the same bundle six months later, one changed option produces a different result. Write it into a file instead of relying on human memory. Put that information into the bundle as something like a PROVENANCE.txt file and the medium becomes self-contained.
Redistributing Red Hat content without a subscription may violate your agreement, so check your organization's license terms first. Keeping the source repository ID in the chain-of-custody record also gives you the evidence you need later when you have to confirm the scope of that license.
Closing — the medium has to stand on its own
There is a single principle in this post. With nothing but the medium in your hand, you should be able to tell what is inside it, where it came from, and how to build it again.
A USB stick holding nothing but rpm files tells you nothing six months later. Add the manifest, the public key, and the creation command, and that medium turns into its own documentation. The extra cost is two text files.
The commands and options were verified against the official documentation on 2026-08-15. They vary by RHEL version, so re-check against the documentation for the version you are running.
Try it yourself
- Hash generator — build SHA-256 checksums by hand to get a feel for the manifest
- Linux terminal — assemble the pipelines from the verification script
- Linux command quiz — review the rpm query options
Previous / next in the series
- Previous: Building a local repository — createrepo_c, repodata, GPG keys
- Next: Modules and versions — reproducing exactly the same state offline
References
현재 단락 (1/109)
On a connected network, if something looks wrong you just download it again. If the hash does not ma...