Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,27 @@ jobs:
name: examples
- dir: e2e/smoke
name: e2e-smoke
- dir: e2e/invalid_key
name: e2e-invalid-key
expect_failure_log: "GPG verification failed"
- dir: e2e/no_gpg_keys
name: e2e-no-gpg-keys
expect_failure_log: "signature verification is required by default"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7.0.1
- uses: bazel-contrib/setup-bazel@0.19.0
- run: USE_BAZEL_VERSION=${{ matrix.bazel-version }} bazel test //...
- if: ${{ !matrix.workspace.expect_failure_log }}
run: USE_BAZEL_VERSION=${{ matrix.bazel-version }} bazel test //...
working-directory: ${{ matrix.workspace.dir }}
- if: ${{ matrix.workspace.expect_failure_log }}
run: |
if USE_BAZEL_VERSION=${{ matrix.bazel-version }} bazel build //... > stderr.log 2>&1; then
echo "Expected build to fail, but it succeeded!"
exit 1
fi
cat stderr.log
grep -q "${{ matrix.workspace.expect_failure_log }}" stderr.log
working-directory: ${{ matrix.workspace.dir }}
- name: Upload test logs
if: failure()
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
# see https://github.com/bazelbuild/bazel/issues/20369
MODULE.bazel.lock
e2e/smoke/MODULE.bazel.lock
e2e/invalid_key/MODULE.bazel.lock
e2e/no_gpg_keys/MODULE.bazel.lock
e2e/invalid_key/stderr.log
e2e/no_gpg_keys/stderr.log
bazel-*
.bazelrc.user
.idea/
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ git_override(
)
```

## GPG / OpenPGP Signature Verification

`rules_distroless` verifies cryptographic OpenPGP signatures on repository indices
(`InRelease` or `Release` + `Release.gpg`) using `gpgv` or `sqv` on `PATH`:

```starlark
apt.sources_list(
architectures = ["amd64", "arm64"],
components = ["main"],
gpg_keys = ["//keys:debian-archive-keyring.gpg"],
suites = ["bookworm"],
types = ["deb"],
uris = ["https://deb.debian.org/debian"],
)
```

> [!NOTE]
> **Keyring Requirements (`gpgv` vs. `sqv`)**: Distribution index files (`InRelease`)
> are often cross-signed by multiple keys (e.g., current release key, successor key, and transition keys).
> - `gpgv` strictly requires that **all** keys that signed the file are present in the keyring; it exits with code 2 if any co-signing key is missing.
> - `sqv` requires at least one valid signature from the keyring.
>
> To ensure reproducible builds across machines regardless of which verifier is installed on `PATH`, keyrings should include all co-signing keys present on the target release files (or the full distribution archive keyring).

# Examples

The [examples](/examples) demonstrate how to accomplish typical tasks such as
Expand Down
322 changes: 312 additions & 10 deletions apt/extensions.bzl

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions apt/private/apt_deb_repository.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -120,22 +120,32 @@ def _filemap(state, name, arch):
return state.filemap[arch][name]

def _add_source_if_not_present(state, source):
(urls, dist, components, architectures) = source
(urls, dist, components, architectures, gpg_keys) = source

for arch in architectures:
for comp in components:
keys = [
"%".join((url, dist, comp, arch))
for url in urls
]
found = any([
key in state.sources
for key in keys
])
found = False
for key in keys:
if key in state.sources:
found = True
(_, _, _, _, old_keys) = state.sources[key]
if sorted([str(k) for k in old_keys]) != sorted([str(k) for k in gpg_keys]):
fail("Conflicting GPG configuration for source '{url}' suite '{dist}' component '{comp}' arch '{arch}': previously registered with gpg_keys = {old}; conflicting definition has gpg_keys = {new}.".format(
url = key.split("%")[0],
dist = dist,
comp = comp,
arch = arch,
old = old_keys,
new = gpg_keys,
))
if found:
continue
for key in keys:
state.sources[key] = (urls, dist, comp, arch)
state.sources[key] = (urls, dist, comp, arch, gpg_keys)

def _create():
state = struct(
Expand Down
79 changes: 79 additions & 0 deletions apt/private/util.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,81 @@ def _prune_uncacheable_facts(indices, formats, used_keys, snapshot_indices):
}
return (cacheable_indices, cacheable_formats)

def _strip_pgp_clearsign_armor(content):
"""Strips PGP cleartext signature framing from InRelease content."""
if "-----BEGIN PGP SIGNED MESSAGE-----" not in content:
return content

lines = content.splitlines()
output_lines = []
in_message = False
in_header = False
for line in lines:
if line.startswith("-----BEGIN PGP SIGNED MESSAGE-----"):
in_header = True
continue
if in_header:
if line.strip() == "":
in_header = False
in_message = True
continue
if line.startswith("-----BEGIN PGP SIGNATURE-----"):
in_message = False
break
if in_message:
if line.startswith("- "):
line = line[2:]
output_lines.append(line)
if not output_lines:
fail("Malformed PGP clearsigned message: failed to extract signed content.")
return "\n".join(output_lines)

def _split_whitespace(s):
"""Splits a string by contiguous whitespace (spaces, tabs, newlines)."""
parts = []
current = ""
for i in range(len(s)):
c = s[i]
if c == " " or c == "\t" or c == "\n" or c == "\r":
if current:
parts.append(current)
current = ""
else:
current += c
if current:
parts.append(current)
return parts

def _parse_release_file(content):
"""Parses SHA256 hashes for index files from Release/InRelease contents."""
hashes = {}
lines = content.splitlines()
in_sha256_section = False
for line in lines:
is_continuation = line.startswith(" ") or line.startswith("\t")
if line.startswith("SHA256:"):
in_sha256_section = True
continue
elif line.startswith("SHA512:") or line.startswith("SHA1:") or line.startswith("MD5Sum:") or (line and not is_continuation):
in_sha256_section = False
if in_sha256_section and is_continuation:
parts = _split_whitespace(line)
if len(parts) == 3:
sha256, _size, path = parts
hashes[path] = sha256
return hashes

def _build_keyring_args(key_paths):
"""Build repeated --keyring arguments for a list of keyring paths."""
args = []
for k in key_paths:
args.extend(["--keyring", str(k)])
return args

def _is_ascii_armored(content):
"""Returns True if the content is an ASCII-armored OpenPGP block."""
return "-----BEGIN PGP" in content

def _warning(rctx, message):
rctx.execute([
"echo",
Expand All @@ -101,4 +176,8 @@ util = struct(
is_snapshot_uri = _is_snapshot_uri,
index_fact_key = _index_fact_key,
prune_uncacheable_facts = _prune_uncacheable_facts,
strip_pgp_clearsign_armor = _strip_pgp_clearsign_armor,
parse_release_file = _parse_release_file,
build_keyring_args = _build_keyring_args,
is_ascii_armored = _is_ascii_armored,
)
3 changes: 3 additions & 0 deletions apt/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ load(":extensions_test.bzl", "extensions_tests")
load(":facts_test.bzl", "facts_tests")
load(":linker_script_test.bzl", "linker_script_tests")
load(":lockfile_test.bzl", "lockfile_tests")
load(":release_test.bzl", "release_tests")
load(":resolution_test.bzl", "resolution_tests")
load(":translate_dependency_set_test.bzl", "translate_dependency_set_tests")
load(":version_test.bzl", "version_tests")
Expand All @@ -25,6 +26,8 @@ linker_script_tests()

lockfile_tests()

release_tests()

translate_dependency_set_tests()

extensions_tests()
180 changes: 180 additions & 0 deletions apt/tests/release_test.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"unit tests for Release file parsing and PGP verification utilities"

load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest")
load("//apt/private:apt_deb_repository.bzl", "deb_repository")
load("//apt/private:util.bzl", "util")

_TEST_SUITE_PREFIX = "release/"

def _strip_pgp_armor_test(ctx):
env = unittest.begin(ctx)

clearsigned = """-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

Origin: Debian
Label: Debian
Suite: bookworm
- - A dash escaped line
Date: Sat, 10 Feb 2024 22:33:13 UTC
-----BEGIN PGP SIGNATURE-----

iQIzBAEBCAAdFiEE...
-----END PGP SIGNATURE-----"""

stripped = util.strip_pgp_clearsign_armor(clearsigned)
asserts.true(env, "Origin: Debian" in stripped)
asserts.true(env, "- A dash escaped line" in stripped)
asserts.false(env, "-----BEGIN PGP SIGNED MESSAGE-----" in stripped)
asserts.false(env, "-----BEGIN PGP SIGNATURE-----" in stripped)

# Unarmored text remains unchanged
plain = "Origin: Debian\nSuite: bookworm"
asserts.equals(env, plain, util.strip_pgp_clearsign_armor(plain))

return unittest.end(env)

def _parse_release_file_test(ctx):
env = unittest.begin(ctx)

release_text = """Origin: Debian
Label: Debian
Suite: bullseye
Version: 11.9
Codename: bullseye
Date: Sat, 10 Feb 2024 22:33:13 UTC
Valid-Until: Sat, 17 Feb 2024 22:33:13 UTC
Architectures: amd64 arm64
Components: main contrib
MD5Sum:
11111111111111111111111111111111 1234 main/binary-amd64/Packages.xz
SHA1:
2222222222222222222222222222222222222222 1234 main/binary-amd64/Packages.xz
SHA256:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 main/binary-amd64/Packages
8f434346648f6b96df89dda901c5176b10f6075361a446da52e96e204f947104 1234 main/binary-amd64/Packages.xz
0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b 5678 main/Contents-amd64.gz
SHA512:
33333333333333333333333333333333333333333333333333333333333333333333333333333333 1234 main/binary-amd64/Packages.xz
"""

hashes = util.parse_release_file(release_text)
asserts.equals(env, 3, len(hashes))
asserts.equals(
env,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
hashes.get("main/binary-amd64/Packages"),
)
asserts.equals(
env,
"8f434346648f6b96df89dda901c5176b10f6075361a446da52e96e204f947104",
hashes.get("main/binary-amd64/Packages.xz"),
)
asserts.equals(
env,
"0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b",
hashes.get("main/Contents-amd64.gz"),
)

# Test tab-delimited and tab-indented Release file
tab_release = """Origin: Debian
Suite: bookworm
SHA256:
\t1111111111111111111111111111111111111111111111111111111111111111\t100\tmain/binary-amd64/Packages.gz
\t2222222222222222222222222222222222222222222222222222222222222222 200\tmain/binary-arm64/Packages.xz
SHA512:
\t3333333333333333333333333333333333333333333333333333333333333333\t100\tmain/binary-amd64/Packages.gz
"""
tab_hashes = util.parse_release_file(tab_release)
asserts.equals(env, 2, len(tab_hashes))
asserts.equals(
env,
"1111111111111111111111111111111111111111111111111111111111111111",
tab_hashes.get("main/binary-amd64/Packages.gz"),
)
asserts.equals(
env,
"2222222222222222222222222222222222222222222222222222222222222222",
tab_hashes.get("main/binary-arm64/Packages.xz"),
)

return unittest.end(env)

def _build_keyring_args_test(ctx):
env = unittest.begin(ctx)

args = util.build_keyring_args(["/path/to/key1.gpg", "/path/to/key2.asc"])
asserts.equals(env, [
"--keyring",
"/path/to/key1.gpg",
"--keyring",
"/path/to/key2.asc",
], args)

return unittest.end(env)

def _deb_repository_stores_gpg_keys_test(ctx):
env = unittest.begin(ctx)

repo = deb_repository.new()
repo.add_source((
["https://deb.debian.org/debian"],
"bookworm",
["main"],
["amd64"],
("@my_repo//:key.gpg", "@my_repo//:extra.asc"),
))
repo.add_source((
["https://custom.repo.org/apt"],
"custom",
["main"],
["amd64"],
("@my_repo//:custom.gpg",),
))

sources = repo.sources()
asserts.true(env, len(sources) > 0)
for key, source in sources.items():
(urls, dist, comp, arch, gpg_keys) = source
if dist == "bookworm":
asserts.equals(env, ("@my_repo//:key.gpg", "@my_repo//:extra.asc"), gpg_keys)
elif dist == "custom":
asserts.equals(env, ("@my_repo//:custom.gpg",), gpg_keys)

return unittest.end(env)

def _is_ascii_armored_test(ctx):
env = unittest.begin(ctx)

# Realistic ASCII-armored public key block
asc_key_sample = """-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v2

mQENBF2X...
=abcd
-----END PGP PUBLIC KEY BLOCK-----"""
asserts.true(env, util.is_ascii_armored(asc_key_sample))

# Armor file headers
asserts.true(env, util.is_ascii_armored("-----BEGIN PGP ARMORED FILE-----\n..."))
asserts.true(env, util.is_ascii_armored("-----BEGIN PGP SIGNED MESSAGE-----\n..."))

# Binary OpenPGP data / non-armored files
asserts.false(env, util.is_ascii_armored("binary-keyring-data-here"))
asserts.false(env, util.is_ascii_armored("Suite: bookworm\nOrigin: Debian"))
asserts.false(env, util.is_ascii_armored(""))

return unittest.end(env)

strip_pgp_armor_test = unittest.make(_strip_pgp_armor_test)
parse_release_file_test = unittest.make(_parse_release_file_test)
build_keyring_args_test = unittest.make(_build_keyring_args_test)
deb_repository_stores_gpg_keys_test = unittest.make(_deb_repository_stores_gpg_keys_test)
is_ascii_armored_test = unittest.make(_is_ascii_armored_test)

def release_tests():
strip_pgp_armor_test(name = _TEST_SUITE_PREFIX + "strip_pgp_armor")
parse_release_file_test(name = _TEST_SUITE_PREFIX + "parse_release_file")
build_keyring_args_test(name = _TEST_SUITE_PREFIX + "build_keyring_args")
deb_repository_stores_gpg_keys_test(name = _TEST_SUITE_PREFIX + "deb_repository_stores_gpg_keys")
is_ascii_armored_test(name = _TEST_SUITE_PREFIX + "is_ascii_armored")
Loading