Air-Gapped Mirrors and a Private CA
Hashes vouch for change, signatures vouch for who
In one line
Import is not "moving files"; it is a procedure of request → download → list → signature → media → verification → installation → record. A hash guarantees that the files were not changed in transit, and a signature guarantees who made that list. With only one of the two, you have half.
Why this was needed
What typically passes through an air-gapped import review is one USB drive and one email. If a hash list file is on the USB drive too, people feel reassured, but that list can be edited together by whoever changed the files. It catches damage in transit but not substitution. Conversely, if you only sign and there is no hash list, it is ambiguous what was signed. And if the reviewing side receives a request that says "latest versions", a new version comes out between the day of the download outside and the day of the review, and the list and the actual files no longer match.
How it works
Write the request with exact versions. Not requests but requests==2.32.3, not rsc.io/quote but v1.5.2. If you write a range or "latest", the result differs depending on the day of the download, and nobody can prove that the review record and the imported items match. Dependencies are not written into the request by a person; you attach to the list what the downloading tool resolved (pip's wheel list, Go's go.sum).
Write the list with relative paths. sha256sum opens the paths written in the list relative to the current directory. If you create it with find . -type f inside the bundle directory, sha256sum -c works inside it wherever you unpack the bundle. The list file itself and the signature file are left out of the list.
Sign the list. There is no need to sign hundreds of files one by one: if you sign a single hash list, the list binds all the files. You create a key with OpenSSL 3's genpkey -algorithm ed25519, sign with pkeyutl -sign -rawin, and verify with pkeyutl -verify -pubin -rawin -sigfile (Ed25519 does not choose a hash separately and takes the original data as it is). Many organizations use GPG or minisign, but the principle is the same.
Send the private key and the public key by different routes. The private key never leaves the PC of the person in charge on the download side. The public key must already be in place in advance on the air-gapped side by a route that is not the media (prior registration, a separate document). If you put the public key on the media too, whoever does the substitution simply puts in their own public key.
Verify signature first. The air-gapped side ① checks the list's signature with the public key registered in advance and ② checks the file hashes with that list. If you reverse the order and look at the hashes first, you will read as "pass" a substituted list that matches its own files perfectly. And a verification script must exit with a nonzero value on failure for the next step (installation) to stop. A verification that only prints a message and exits with 0 is not verification in automation.
What it looks like in the field
In this lab image, when I bundled requests 2.32.3 (5 wheels) and rsc.io/quote v1.5.2 (3 module zips, mostly x/text at 4.8MB) into one bundle, there were 48 files and a little over 5MB. That is because, besides the 5 wheels and 3 module zips, the auxiliary files piled up in Go's cache/download (.mod, .info, .ziphash, and the lookup records of the checksum database) go in together. It is why the list must be made by find, not by a person. On top of that come one SHA256SUMS and one 64-byte Ed25519 signature. The grader runs your verification script on two copies of the bundle. One has a single byte of a wheel changed (it must be caught at the hash), and the other has SHA256SUMS fixed as well to match the changed file (it must be caught at the signature). A script that lets the second pass cannot stop substitution.
The record is part of the procedure too. You must leave what was requested, how many files the bundle had, what the hash of the list itself is, with which key the signature was verified, and what was installed, so that you can later answer "what was it that came in at that time".
What you will do in the next lab
You write a request, download Python wheels and Go modules into one bundle, and attach SHA256SUMS and an Ed25519 signature. You make the media (a tar) without the private key, unpack it on the air-gapped side, and write a verification script with signature first and hash next. The grader runs that script on a substituted copy. Finally, with the outside blocked, you install from the bundle alone and leave the import record as JSON.
Reference documents: OpenSSL genpkey · OpenSSL pkeyutl · GNU coreutils sha256sum · pip download · Go Modules Reference