Skip to content

Commit acec0de

Browse files
committed
feat: add signing functionality to apt-package-function
Enable package signing for apt-package-function controlled repositories.
1 parent acec0de commit acec0de

9 files changed

Lines changed: 1107 additions & 471 deletions

File tree

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,38 @@ This creates an additional blob container (`python`) in the storage account to
6464
hold the compiled function application zip file; the function application is
6565
run directly from that zip file.
6666

67+
## Signing the repository
68+
69+
By default the repository is unsigned and clients trust it via `[trusted=yes]`.
70+
You can instead have the function sign the repository's `Release` file with a
71+
GPG key, so clients verify it against a public key.
72+
73+
Either bring your own ASCII-armored private key:
74+
75+
```bash
76+
poetry run create-resources --gpg-key ./my-signing-key.asc <resource_group_name>
77+
```
78+
79+
or have one generated for you:
80+
81+
```bash
82+
poetry run create-resources --autogenerate-gpg-key <resource_group_name>
83+
```
84+
85+
When signing is enabled:
86+
87+
- The private key is stored in an Azure Key Vault. The function app reads it via
88+
a Key Vault reference resolved by its managed identity — the key value never
89+
appears in the app's configuration.
90+
- The public key is published to the package container as `public-key.asc`.
91+
- The function generates and signs `Release` (producing `InRelease` and
92+
`Release.gpg`) each time the repository is regenerated.
93+
94+
On the client, install the public key into `/etc/apt/keyrings/` and reference it
95+
from the sources line (`create-resources` prints the exact commands, including
96+
the `deb [signed-by=…]` line, on success). The bring-your-own key must be
97+
exported **without** a passphrase so the function can sign non-interactively.
98+
6799
# Design
68100

69101
The function app works as follows:
@@ -79,6 +111,9 @@ The function app works as follows:
79111
- All `.package` files are iterated over, downloaded, and combined into a
80112
single `Package` file, which is then uploaded. A `Packages.xz` file is also
81113
created.
114+
- If signing is enabled, a `Release` file is generated (with MD5/SHA1/SHA256
115+
digests of the `Packages` files) and signed, producing `InRelease` (inline
116+
signature) and `Release.gpg` (detached signature).
82117

83118
## Speed of repository update
84119

function_app.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
"""A function app to manage a Debian repository in Azure Blob Storage."""
44

55
import contextlib
6+
import hashlib
67
import io
78
import logging
89
import lzma
910
import os
1011
import tempfile
12+
from email.utils import formatdate
1113
from pathlib import Path
1214
from typing import Generator, Optional
1315

@@ -28,6 +30,10 @@
2830
CONTAINER_NAME = os.environ["BLOB_CONTAINER"]
2931
DEB_CHECK_KEY = "DebLastModified"
3032

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+
3137

3238
@contextlib.contextmanager
3339
def temporary_filename() -> Generator[str, None, None]:
@@ -198,6 +204,66 @@ def create_packages(self) -> None:
198204
self.package_file_xz.upload_blob(compressed_data, overwrite=True)
199205
log.info("Created Packages.xz file")
200206

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+
201267

202268
@app.function_name(name="eventGridTrigger")
203269
@app.event_grid_trigger(arg_name="event")

0 commit comments

Comments
 (0)