|
3 | 3 | """A function app to manage a Debian repository in Azure Blob Storage.""" |
4 | 4 |
|
5 | 5 | import contextlib |
| 6 | +import hashlib |
6 | 7 | import io |
7 | 8 | import logging |
8 | 9 | import lzma |
9 | 10 | import os |
10 | 11 | import tempfile |
| 12 | +from email.utils import formatdate |
11 | 13 | from pathlib import Path |
12 | 14 | from typing import Generator, Optional |
13 | 15 |
|
|
28 | 30 | CONTAINER_NAME = os.environ["BLOB_CONTAINER"] |
29 | 31 | DEB_CHECK_KEY = "DebLastModified" |
30 | 32 |
|
| 33 | +# Signing is enabled iff a GPG private key is provided (via a Key Vault |
| 34 | +# reference app setting). When set, the repository Release file is signed. |
| 35 | +GPG_PRIVATE_KEY = os.environ.get("GPG_PRIVATE_KEY") |
| 36 | + |
31 | 37 |
|
32 | 38 | @contextlib.contextmanager |
33 | 39 | def temporary_filename() -> Generator[str, None, None]: |
@@ -198,6 +204,66 @@ def create_packages(self) -> None: |
198 | 204 | self.package_file_xz.upload_blob(compressed_data, overwrite=True) |
199 | 205 | log.info("Created Packages.xz file") |
200 | 206 |
|
| 207 | + # If signing is enabled, generate and sign a Release file over the |
| 208 | + # Packages files we just produced. |
| 209 | + if GPG_PRIVATE_KEY: |
| 210 | + self.create_release( |
| 211 | + {"Packages": packages_bytes, "Packages.xz": compressed_data}, |
| 212 | + GPG_PRIVATE_KEY, |
| 213 | + ) |
| 214 | + |
| 215 | + def create_release(self, files: dict, armored_private_key: str) -> None: |
| 216 | + """Build, sign and upload the Release / InRelease / Release.gpg files.""" |
| 217 | + release = build_release(files) |
| 218 | + inrelease, release_gpg = sign_release(release, armored_private_key) |
| 219 | + |
| 220 | + for name, data in ( |
| 221 | + ("Release", release), |
| 222 | + ("InRelease", inrelease), |
| 223 | + ("Release.gpg", release_gpg), |
| 224 | + ): |
| 225 | + self.container_client.get_blob_client(name).upload_blob( |
| 226 | + data, overwrite=True |
| 227 | + ) |
| 228 | + log.info("Created %s file", name) |
| 229 | + |
| 230 | + |
| 231 | +def build_release(files: dict) -> str: |
| 232 | + """Build a flat-repository Release file over the given {name: bytes}. |
| 233 | +
|
| 234 | + Includes size and MD5/SHA1/SHA256 digests for each file, as apt requires. |
| 235 | + """ |
| 236 | + lines = [ |
| 237 | + "Origin: apt-package-function", |
| 238 | + "Label: apt-package-function", |
| 239 | + f"Date: {formatdate(usegmt=True)}", |
| 240 | + ] |
| 241 | + for algo_name, algo in (("MD5Sum", "md5"), ("SHA1", "sha1"), ("SHA256", "sha256")): |
| 242 | + lines.append(f"{algo_name}:") |
| 243 | + for name, data in files.items(): |
| 244 | + digest = hashlib.new(algo, data).hexdigest() |
| 245 | + lines.append(f" {digest} {len(data)} {name}") |
| 246 | + return "\n".join(lines) + "\n" |
| 247 | + |
| 248 | + |
| 249 | +def sign_release(release: str, armored_private_key: str) -> tuple: |
| 250 | + """Sign a Release file, returning (InRelease bytes, Release.gpg bytes). |
| 251 | +
|
| 252 | + InRelease is an inline (cleartext) signed document; Release.gpg is a |
| 253 | + detached armored signature. Imported lazily so the module still loads if |
| 254 | + signing is disabled and pgpy is unavailable. |
| 255 | + """ |
| 256 | + import pgpy |
| 257 | + |
| 258 | + key, _ = pgpy.PGPKey.from_blob(armored_private_key) |
| 259 | + |
| 260 | + detached = key.sign(release) |
| 261 | + |
| 262 | + message = pgpy.PGPMessage.new(release, cleartext=True) |
| 263 | + message |= key.sign(message) |
| 264 | + |
| 265 | + return str(message).encode(), str(detached).encode() |
| 266 | + |
201 | 267 |
|
202 | 268 | @app.function_name(name="eventGridTrigger") |
203 | 269 | @app.event_grid_trigger(arg_name="event") |
|
0 commit comments