Package registries phase 4: apt, with a Sequoia NIF

6bfb936 · Gabriel Morell · 2026-09-11 14:43

26 files +2863 -4
Message
{commit_body(@commit)}

Files changed

modified .gitignore
+10 −0
@@ -57,3 +57,13 @@ npm-debug.log
57 57 # notes. A shared .claude/settings.json, if one is ever added, stays
58 58 # tracked.
59 59 /.claude/settings.local.json
60 +
61 +# Rust build artifacts for the NIF crates, and the shared objects
62 +# Rustler copies out of them — `mix compile` rebuilds both, and the
63 +# Docker build compiles them from source.
64 +#
65 +# (native/gitgud_gix/target and priv/native/gitgud_gix.so predate this
66 +# rule and stay tracked, so they still churn on every build.
67 +# Untracking them is its own change, not this one's to make.)
68 +/native/*/target/
69 +/priv/native/
modified Dockerfile
+6 −2
@@ -70,8 +70,12 @@ COPY priv priv
70 70
71 71 COPY lib lib
72 72
73 −# Rust source for the gitgud_gix NIF — Rustler invokes `cargo` from
74 −# this directory during `mix compile`, so it must be present.
73 +# Rust source for the NIF crates (gitgud_gix, gitgud_apt) — Rustler
74 +# invokes `cargo` from this directory during `mix compile`, so it must
75 +# be present. gitgud_apt links libssl/libcrypto, which `libssl-dev`
76 +# above already provides for the builder and the runner's `openssl`
77 +# package provides at runtime — sequoia's OpenSSL backend was chosen
78 +# precisely so neither image needs a new package.
75 79 COPY native native
76 80
77 81 # Compile the release
modified lib/git_gud/packages.ex
+25 −1
@@ -375,6 +375,11 @@ defmodule GitGud.Packages do
375 375 lazy generation is what keeps "publish something" from needing a
376 376 separate provisioning step.
377 377
378 + The key *kind* follows the format: Hex verifies an RSA signature
379 + against a PEM public key, apt verifies an OpenPGP signature against
380 + an armored certificate. The field names say "pem" for both because
381 + the storage is the same; only the bytes differ.
382 +
378 383 Generation races are resolved by the unique index: a loser re-reads
379 384 the winner's row rather than clobbering it, because a public key that
380 385 changed underneath a consumer is worse than a wasted keygen.
@@ -387,7 +392,7 @@ defmodule GitGud.Packages do
387 392 end
388 393
389 394 defp generate_signing_key(%Repository{id: rid} = repo, format) do
390 − {private_pem, public_pem} = Token.generate_keypair()
395 + {:ok, public_pem, private_pem} = generate_keypair(repo, format)
391 396 {:ok, wrapped} = GitGud.Secrets.encrypt(private_pem)
392 397
393 398 %SigningKey{repository_id: rid}
@@ -413,6 +418,25 @@ defmodule GitGud.Packages do
413 418 end
414 419 end
415 420
421 + # apt needs an OpenPGP certificate, which OTP cannot produce — see
422 + # `GitGud.Packages.Apt.Native`. Everything else wants RSA in PEM,
423 + # which is the machinery the OCI token signer already has.
424 + defp generate_keypair(%Repository{} = repo, "deb") do
425 + repo = Repo.preload(repo, [:owner, :organization])
426 + handle = GitGud.Repositories.Storage.repo_handle(repo)
427 + user_id = "#{handle}/#{repo.name} (GitGud package registry)"
428 +
429 + case GitGud.Packages.Apt.Native.generate_key(user_id) do
430 + {:ok, {public, secret}} -> {:ok, public, secret}
431 + {:error, reason} -> {:error, reason}
432 + end
433 + end
434 +
435 + defp generate_keypair(%Repository{}, _format) do
436 + {private_pem, public_pem} = Token.generate_keypair()
437 + {:ok, public_pem, private_pem}
438 + end
439 +
416 440 defp unwrap_key(%SigningKey{} = key) do
417 441 case GitGud.Secrets.decrypt(%{
418 442 ciphertext: key.private_key_ciphertext,
added lib/git_gud/packages/apt/native.ex
+82 −0
@@ -0,0 +1,82 @@
1 +defmodule GitGud.Packages.Apt.Native do
2 + @moduledoc """
3 + The two things the apt repository needs that OTP cannot do, via the
4 + `native/gitgud_apt` Rustler NIF.
5 +
6 + **OpenPGP signatures.** apt requires a signed `Release`, and
7 + `:public_key` speaks PKCS and X.509 — OpenPGP is a different packet
8 + format with its own key material, and nothing in the Erlang
9 + distribution implements it. This is the only format in the registry
10 + that needed a NIF; the rest get by on `:crypto` and `:public_key`.
11 +
12 + **xz and zstd.** A `.deb`'s control member is `control.tar.xz` on
13 + anything since squeeze and `control.tar.zst` on Ubuntu, and
14 + `:erl_tar` handles gzip and nothing else. The same encoder produces
15 + the `Packages.xz` a Debian repository is expected to offer.
16 +
17 + ## Why these backends
18 +
19 + `sequoia-openpgp` defaults to Nettle, which would put `libnettle`
20 + and `libgmp` in the runtime image. This project ships k8s manifests;
21 + a NIF that changes the image's package list is a deployment change
22 + wearing a dependency's clothes. The pure-Rust backend needs no
23 + system libraries at all, but sequoia gates it behind
24 + `allow-variable-time-crypto`, and this code holds a private key. The
25 + OpenSSL backend links libssl and libcrypto, which are already in
26 + both images — the builder installs `libssl-dev` for gix and the
27 + runner installs `openssl` for the Erlang runtime — and is
28 + constant-time. So: OpenSSL, and no image change at all.
29 +
30 + The compression crates (`lzma-rs`, `ruzstd`) are pure Rust for the
31 + same reason: no new system libraries.
32 +
33 + Keys and signatures cross the boundary as armored ASCII, so nothing
34 + on the Elixir side handles OpenPGP packets.
35 + """
36 +
37 + use Rustler, otp_app: :git_gud, crate: "gitgud_apt"
38 +
39 + @doc """
40 + Generate a signing certificate for `user_id`. Returns
41 + `{:ok, {public_armored, secret_armored}}` — the public half goes in
42 + a client's `/etc/apt/keyrings`, the secret half is stored encrypted
43 + and never leaves the forge.
44 + """
45 + @spec generate_key(String.t()) :: {:ok, {String.t(), String.t()}} | {:error, String.t()}
46 + def generate_key(_user_id), do: :erlang.nif_error(:nif_not_loaded)
47 +
48 + @doc """
49 + Clearsign `data` — the `InRelease` shape, signature and signed text
50 + in one file, which is what apt prefers.
51 + """
52 + @spec sign_clearsign(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
53 + def sign_clearsign(_secret_armored, _data), do: :erlang.nif_error(:nif_not_loaded)
54 +
55 + @doc """
56 + Produce a detached armored signature — the `Release.gpg` shape,
57 + which older apt falls back to when `InRelease` is absent.
58 + """
59 + @spec sign_detached(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
60 + def sign_detached(_secret_armored, _data), do: :erlang.nif_error(:nif_not_loaded)
61 +
62 + @doc """
63 + Verify a detached signature. Not used to serve anything — it lets
64 + tests assert what apt will assert.
65 + """
66 + @spec verify_detached(String.t(), String.t(), String.t()) ::
67 + {:ok, boolean()} | {:error, String.t()}
68 + def verify_detached(_public_armored, _data, _signature_armored),
69 + do: :erlang.nif_error(:nif_not_loaded)
70 +
71 + @doc "Decompress an xz stream."
72 + @spec xz_decompress(binary()) :: {:ok, binary()} | {:error, String.t()}
73 + def xz_decompress(_data), do: :erlang.nif_error(:nif_not_loaded)
74 +
75 + @doc "Compress to xz."
76 + @spec xz_compress(binary()) :: {:ok, binary()} | {:error, String.t()}
77 + def xz_compress(_data), do: :erlang.nif_error(:nif_not_loaded)
78 +
79 + @doc "Decompress a zstd stream."
80 + @spec zstd_decompress(binary()) :: {:ok, binary()} | {:error, String.t()}
81 + def zstd_decompress(_data), do: :erlang.nif_error(:nif_not_loaded)
82 +end
modified lib/git_gud/packages/format.ex
+2 −1
@@ -48,7 +48,8 @@ defmodule GitGud.Packages.Format do
48 48 @default_adapters %{
49 49 "pypi" => GitGud.Packages.Formats.Pypi,
50 50 "hex" => GitGud.Packages.Formats.Hex,
51 − "cargo" => GitGud.Packages.Formats.Cargo
51 + "cargo" => GitGud.Packages.Formats.Cargo,
52 + "deb" => GitGud.Packages.Formats.Deb
52 53 }
53 54
54 55 @typedoc """
added lib/git_gud/packages/formats/deb.ex
+354 −0

Click to load diff…

added lib/git_gud/packages/formats/deb/control.ex
+177 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/packages.ex
+14 −0

Click to load diff…

added native/gitgud_apt/Cargo.lock
+1470 −0

Click to load diff…

added native/gitgud_apt/Cargo.toml
+49 −0

Click to load diff…

added native/gitgud_apt/src/lib.rs
+273 −0

Click to load diff…

added test/git_gud_web/apt_registry_test.exs
+401 −0

Click to load diff…

Parents: 173e2a6