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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ This creates an additional blob container (`python`) in the storage account to
hold the compiled function application zip file; the function application is
run directly from that zip file.

## Signing the repository

By default the repository is unsigned and clients trust it via `[trusted=yes]`.
You can instead have the function sign the repository's `Release` file with a
GPG key, so clients verify it against a public key.

Either bring your own ASCII-armored private key:

```bash
poetry run create-resources --gpg-key ./my-signing-key.asc <resource_group_name>
```

or have one generated for you:

```bash
poetry run create-resources --autogenerate-gpg-key <resource_group_name>
```

When signing is enabled:

- The private key is stored in an Azure Key Vault. The function app reads it via
a Key Vault reference resolved by its managed identity — the key value never
appears in the app's configuration.
- The public key is published to the package container as `public-key.asc`.
- The function generates and signs `Release` (producing `InRelease` and
`Release.gpg`) each time the repository is regenerated.

On the client, install the public key into `/etc/apt/keyrings/` and reference it
from the sources line (`create-resources` prints the exact commands, including
the `deb [signed-by=…]` line, on success). The bring-your-own key must be
exported **without** a passphrase so the function can sign non-interactively.

# Design

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

## Speed of repository update

Expand Down
66 changes: 66 additions & 0 deletions function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
"""A function app to manage a Debian repository in Azure Blob Storage."""

import contextlib
import hashlib
import io
import logging
import lzma
import os
import tempfile
from email.utils import formatdate
from pathlib import Path
from typing import Generator, Optional

Expand All @@ -28,6 +30,10 @@
CONTAINER_NAME = os.environ["BLOB_CONTAINER"]
DEB_CHECK_KEY = "DebLastModified"

# Signing is enabled iff a GPG private key is provided (via a Key Vault
# reference app setting). When set, the repository Release file is signed.
GPG_PRIVATE_KEY = os.environ.get("GPG_PRIVATE_KEY")


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

# If signing is enabled, generate and sign a Release file over the
# Packages files we just produced.
if GPG_PRIVATE_KEY:
self.create_release(
{"Packages": packages_bytes, "Packages.xz": compressed_data},
GPG_PRIVATE_KEY,
)

def create_release(self, files: dict, armored_private_key: str) -> None:
"""Build, sign and upload the Release / InRelease / Release.gpg files."""
release = build_release(files)
inrelease, release_gpg = sign_release(release, armored_private_key)

for name, data in (
("Release", release),
("InRelease", inrelease),
("Release.gpg", release_gpg),
):
self.container_client.get_blob_client(name).upload_blob(
data, overwrite=True
)
log.info("Created %s file", name)


def build_release(files: dict) -> str:
"""Build a flat-repository Release file over the given {name: bytes}.

Includes size and MD5/SHA1/SHA256 digests for each file, as apt requires.
"""
lines = [
"Origin: apt-package-function",
"Label: apt-package-function",
f"Date: {formatdate(usegmt=True)}",
]
for algo_name, algo in (("MD5Sum", "md5"), ("SHA1", "sha1"), ("SHA256", "sha256")):
lines.append(f"{algo_name}:")
for name, data in files.items():
digest = hashlib.new(algo, data).hexdigest()
lines.append(f" {digest} {len(data)} {name}")
return "\n".join(lines) + "\n"


def sign_release(release: str, armored_private_key: str) -> tuple:
"""Sign a Release file, returning (InRelease bytes, Release.gpg bytes).

InRelease is an inline (cleartext) signed document; Release.gpg is a
detached armored signature. Imported lazily so the module still loads if
signing is disabled and pgpy is unavailable.
"""
import pgpy

key, _ = pgpy.PGPKey.from_blob(armored_private_key)

detached = key.sign(release)

message = pgpy.PGPMessage.new(release, cleartext=True)
message |= key.sign(message)

return str(message).encode(), str(detached).encode()


@app.function_name(name="eventGridTrigger")
@app.event_grid_trigger(arg_name="event")
Expand Down
Loading
Loading