-
Notifications
You must be signed in to change notification settings - Fork 375
Add signature checking to install-debs.py #15374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ | |
import asyncio | ||
import aiohttp | ||
import gzip | ||
import hashlib | ||
import os | ||
import re | ||
import shutil | ||
|
@@ -16,7 +17,7 @@ | |
from collections import deque | ||
from functools import cmp_to_key | ||
|
||
async def download_file(session, url, dest_path, max_retries=3, retry_delay=2, timeout=60): | ||
async def download_file(session, url, dest_path, max_retries=3, retry_delay=2, timeout=60, checksum=None): | ||
"""Asynchronous file download with retries.""" | ||
attempt = 0 | ||
while attempt < max_retries: | ||
|
@@ -25,6 +26,13 @@ async def download_file(session, url, dest_path, max_retries=3, retry_delay=2, t | |
if response.status == 200: | ||
with open(dest_path, "wb") as f: | ||
content = await response.read() | ||
|
||
# verify checksum if provided | ||
if checksum: | ||
sha256 = hashlib.sha256(content).hexdigest() | ||
if sha256 != checksum: | ||
raise Exception(f"SHA256 mismatch for {url}: expected {checksum}, got {sha256}") | ||
|
||
f.write(content) | ||
print(f"Downloaded {url} at {dest_path}") | ||
return | ||
|
@@ -51,22 +59,21 @@ async def download_deb_files_parallel(mirror, packages, tmp_dir): | |
if filename: | ||
url = f"{mirror}/{filename}" | ||
dest_path = os.path.join(tmp_dir, os.path.basename(filename)) | ||
tasks.append(asyncio.create_task(download_file(session, url, dest_path))) | ||
tasks.append(asyncio.create_task(download_file(session, url, dest_path, checksum=info.get("SHA256")))) | ||
|
||
await asyncio.gather(*tasks) | ||
|
||
async def download_package_index_parallel(mirror, arch, suites): | ||
async def download_package_index_parallel(mirror, arch, suites, check_sig, keyring): | ||
"""Download package index files for specified suites and components entirely in memory.""" | ||
tasks = [] | ||
timeout = aiohttp.ClientTimeout(total=60) | ||
|
||
async with aiohttp.ClientSession(timeout=timeout) as session: | ||
for suite in suites: | ||
for component in ["main", "universe"]: | ||
url = f"{mirror}/dists/{suite}/{component}/binary-{arch}/Packages.gz" | ||
tasks.append(fetch_and_decompress(session, url)) | ||
tasks.append(fetch_and_decompress(session, mirror, arch, suite, component, check_sig, keyring)) | ||
|
||
results = await asyncio.gather(*tasks, return_exceptions=True) | ||
results = await asyncio.gather(*tasks) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is intentional. We need to continue on error here otherwise it fails the build. Some index files are optional and may not be available for certain distro. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's already handled by fetch_and_decompress returning None when response.status is not 200
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It fails from this script when it doesn't find some index. That's why I added success and error logging here. apt behavior is the same, it warns and continue for the missing index. Lets see if it fails dotnet/dotnet-buildtools-prereqs-docker#1310. Local build was failing when I applied your patch. I suggest you open a similar PR to test variations. You can update loongarch with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are other issues:
I will now revert this line from the PR, and highly recommend you open similar PR downstream with that line in LA64 and RA64 net10.0 docksfiles so we have some coverage. RUN rm -rf /scripts && git clone https://github.com/dotnet/arcade --single-branch --depth 1 -b akoeplinger-patch-1 /scripts @akoeplinger, this PR is great, my intention with the feedback is we get the local and cloud scenarios right. For local testing, I'm using podman desktop on macOS for loongarch to test without There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @am11 I tried locally and it fails because we don't have Was this fetched by debootstrap before?
No worries, I'm still on vacation till tomorrow so I can't spend a lot of time on this yet 😄 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
it will resolve both issues. |
||
|
||
merged_content = "" | ||
for result in results: | ||
|
@@ -77,21 +84,73 @@ async def download_package_index_parallel(mirror, arch, suites): | |
|
||
return merged_content | ||
|
||
async def fetch_and_decompress(session, url): | ||
async def fetch_and_decompress(session, mirror, arch, suite, component, check_sig, keyring): | ||
"""Fetch and decompress the Packages.gz file.""" | ||
|
||
path = f"{component}/binary-{arch}/Packages.gz" | ||
url = f"{mirror}/dists/{suite}/{path}" | ||
|
||
try: | ||
async with session.get(url) as response: | ||
if response.status == 200: | ||
compressed_data = await response.read() | ||
decompressed_data = gzip.decompress(compressed_data).decode('utf-8') | ||
print(f"Downloaded index: {url}") | ||
|
||
if check_sig: | ||
# Verify the package index against the sha256 recorded in the Release file | ||
release_file_content = await fetch_release_file(session, mirror, suite, keyring) | ||
packages_sha = parse_release_file(release_file_content, path) | ||
|
||
sha256 = hashlib.sha256(compressed_data).hexdigest() | ||
if sha256 != packages_sha: | ||
raise Exception(f"SHA256 mismatch for {path}: expected {packages_sha}, got {sha256}") | ||
print(f"Checksum verified for {path}") | ||
|
||
return decompressed_data | ||
else: | ||
print(f"Skipped index: {url} (doesn't exist)") | ||
return None | ||
except Exception as e: | ||
print(f"Error fetching {url}: {e}") | ||
|
||
async def fetch_release_file(session, mirror, suite, keyring): | ||
"""Fetch Release and Release.gpg files and verify the signature.""" | ||
|
||
release_url = f"{mirror}/dists/{suite}/Release" | ||
release_gpg_url = f"{mirror}/dists/{suite}/Release.gpg" | ||
|
||
with tempfile.NamedTemporaryFile() as release_file, tempfile.NamedTemporaryFile() as release_gpg_file: | ||
await download_file(session, release_url, release_file.name) | ||
await download_file(session, release_gpg_url, release_gpg_file.name) | ||
|
||
keyring_arg = f"--keyring {keyring}" if keyring != '' else '' | ||
|
||
print("Verifying signature of Release with Release.gpg.") | ||
verify_command = f"gpg {keyring_arg} --verify {release_gpg_file.name} {release_file.name}" | ||
result = subprocess.run(verify_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
|
||
if result.returncode != 0: | ||
raise Exception(f"Signature verification failed: {result.stderr.decode('utf-8')}") | ||
|
||
print("Signature verified successfully.") | ||
|
||
with open(release_file.name) as f: return f.read() | ||
|
||
def parse_release_file(content, path): | ||
"""Parses the Release file and returns sha256 checksum of the specified path.""" | ||
|
||
# data looks like this: | ||
# <checksum> <size> <path> | ||
matches = re.findall(r'^ (\S*) +(\S*) +(\S*)$', content, re.MULTILINE) | ||
|
||
for entry in matches: | ||
# the file has both md5 and sha256 checksums, we want sha256 which has a length of 64 | ||
if entry[2] == path and len(entry[0]) == 64: | ||
return entry[0] | ||
|
||
raise Exception(f"Could not find checksum for {path} in Release file.") | ||
|
||
def parse_debian_version(version): | ||
"""Parse a Debian package version into epoch, upstream version, and revision.""" | ||
match = re.match(r'^(?:(\d+):)?([^-]+)(?:-(.+))?$', version) | ||
|
@@ -171,13 +230,15 @@ def parse_package_index(content): | |
filename = fields.get("Filename") | ||
depends = fields.get("Depends") | ||
provides = fields.get("Provides", None) | ||
sha256 = fields.get("SHA256") | ||
|
||
# Only update if package_name is not in packages or if the new version is higher | ||
if package_name not in packages or compare_debian_versions(version, packages[package_name]["Version"]) > 0: | ||
packages[package_name] = { | ||
"Version": version, | ||
"Filename": filename, | ||
"Depends": depends | ||
"Depends": depends, | ||
"SHA256": sha256 | ||
} | ||
|
||
# Update aliases if package provides any alternatives | ||
|
@@ -301,6 +362,8 @@ def finalize_setup(rootfsdir): | |
parser.add_argument('--suite', required=True, action='append', help='Specify one or more repository suites to collect index data.') | ||
parser.add_argument("--mirror", required=False, help="Mirror (e.g., http://ftp.debian.org/debian-ports etc.)") | ||
parser.add_argument("--artool", required=False, default="ar", help="ar tool to extract debs (e.g., ar, llvm-ar etc.)") | ||
parser.add_argument("--force-check-gpg", required=False, action='store_true', help="Verify the packages against signatures in Release file.") | ||
parser.add_argument("--keyring", required=False, default='', help="Keyring file to check signature of Release file.") | ||
parser.add_argument("packages", nargs="+", help="List of package names to be installed.") | ||
|
||
args = parser.parse_args() | ||
|
@@ -324,7 +387,7 @@ def finalize_setup(rootfsdir): | |
|
||
print(f"Creating rootfs. rootfsdir: {args.rootfsdir}, distro: {args.distro}, arch: {args.arch}, suites: {args.suite}, mirror: {args.mirror}") | ||
|
||
package_index_content = asyncio.run(download_package_index_parallel(args.mirror, args.arch, args.suite)) | ||
package_index_content = asyncio.run(download_package_index_parallel(args.mirror, args.arch, args.suite, args.force_check_gpg, args.keyring)) | ||
|
||
packages_info, aliases = parse_package_index(package_index_content) | ||
|
||
|
Uh oh!
There was an error while loading. Please reload this page.