neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Package registries phase 2: Hex
4869a7f · Gabriel Morell · 2026-09-11 14:25
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/packages.ex
+62
−0
@@ -18,6 +18,8 @@ defmodule GitGud.Packages do
| 18 | 18 | alias GitGud.Packages.PackageFile |
| 19 | 19 | alias GitGud.Packages.PackageVersion |
| 20 | 20 | alias GitGud.Packages.RegistryToken |
| 21 | + alias GitGud.Packages.SigningKey | |
| 22 | + alias GitGud.Packages.Token | |
| 21 | 23 | alias GitGud.Repo |
| 22 | 24 | alias GitGud.Repositories.Repository |
| 23 | 25 | alias GitGud.Storage |
@@ -362,4 +364,64 @@ defmodule GitGud.Packages do
| 362 | 364 | defp storage_key(repo_id, format, sha256) do |
| 363 | 365 | Enum.join([repo_id, format, binary_part(sha256, 0, 2), sha256], "/") |
| 364 | 366 | end |
| 367 | + | |
| 368 | + # ── per-repository signing keys ────────────────────────────────────── | |
| 369 | + | |
| 370 | + @doc """ | |
| 371 | + The repository's keypair for `format`, generating one on first use. | |
| 372 | + | |
| 373 | + Returns `{:ok, %{public_pem: binary, private_pem: binary}}`. Hex and | |
| 374 | + apt both need a key before they can serve anything at all, so | |
| 375 | + lazy generation is what keeps "publish something" from needing a | |
| 376 | + separate provisioning step. | |
| 377 | + | |
| 378 | + Generation races are resolved by the unique index: a loser re-reads | |
| 379 | + the winner's row rather than clobbering it, because a public key that | |
| 380 | + changed underneath a consumer is worse than a wasted keygen. | |
| 381 | + """ | |
| 382 | + def signing_key(%Repository{id: rid} = repo, format) do | |
| 383 | + case Repo.get_by(SigningKey, repository_id: rid, format: format) do | |
| 384 | + %SigningKey{} = key -> unwrap_key(key) | |
| 385 | + nil -> generate_signing_key(repo, format) | |
| 386 | + end | |
| 387 | + end | |
| 388 | + | |
| 389 | + defp generate_signing_key(%Repository{id: rid} = repo, format) do | |
| 390 | + {private_pem, public_pem} = Token.generate_keypair() | |
| 391 | + {:ok, wrapped} = GitGud.Secrets.encrypt(private_pem) | |
| 392 | + | |
| 393 | + %SigningKey{repository_id: rid} | |
| 394 | + |> SigningKey.changeset(%{ | |
| 395 | + format: format, | |
| 396 | + public_key: public_pem, | |
| 397 | + private_key_ciphertext: wrapped.ciphertext, | |
| 398 | + private_key_iv: wrapped.iv, | |
| 399 | + private_key_tag: wrapped.tag, | |
| 400 | + key_id: wrapped.key_id | |
| 401 | + }) | |
| 402 | + |> Repo.insert() | |
| 403 | + |> case do | |
| 404 | + {:ok, _key} -> | |
| 405 | + {:ok, %{public_pem: public_pem, private_pem: private_pem}} | |
| 406 | + | |
| 407 | + {:error, _changeset} -> | |
| 408 | + # Another request generated one between our read and our write. | |
| 409 | + case Repo.get_by(SigningKey, repository_id: rid, format: format) do | |
| 410 | + %SigningKey{} = key -> unwrap_key(key) | |
| 411 | + nil -> signing_key(repo, format) | |
| 412 | + end | |
| 413 | + end | |
| 414 | + end | |
| 415 | + | |
| 416 | + defp unwrap_key(%SigningKey{} = key) do | |
| 417 | + case GitGud.Secrets.decrypt(%{ | |
| 418 | + ciphertext: key.private_key_ciphertext, | |
| 419 | + iv: key.private_key_iv, | |
| 420 | + tag: key.private_key_tag, | |
| 421 | + key_id: key.key_id | |
| 422 | + }) do | |
| 423 | + {:ok, private_pem} -> {:ok, %{public_pem: key.public_key, private_pem: private_pem}} | |
| 424 | + {:error, _} = err -> err | |
| 425 | + end | |
| 426 | + end | |
| 365 | 427 | end |
modified
lib/git_gud/packages/format.ex
+4
−1
@@ -45,7 +45,10 @@ defmodule GitGud.Packages.Format do
| 45 | 45 | # distribution API itself, so `oci` exists only as a `format` value |
| 46 | 46 | # the packages UI can list alongside everything else. Each remaining |
| 47 | 47 | # format appears here as its adapter lands. |
| 48 | − @default_adapters %{"pypi" => GitGud.Packages.Formats.Pypi} | |
| 48 | + @default_adapters %{ | |
| 49 | + "pypi" => GitGud.Packages.Formats.Pypi, | |
| 50 | + "hex" => GitGud.Packages.Formats.Hex | |
| 51 | + } | |
| 49 | 52 | |
| 50 | 53 | @typedoc """ |
| 51 | 54 | Request context handed to an adapter. |
added
lib/git_gud/packages/formats/hex.ex
+293
−0
@@ -0,0 +1,293 @@
| 1 | +defmodule GitGud.Packages.Formats.Hex do | |
| 2 | + @moduledoc """ | |
| 3 | + A Hex registry v2 repository, per git repository. | |
| 4 | + | |
| 5 | + mix hex.repo add <name> https://<host>/pkg/hex/<owner>/<repo> \\ | |
| 6 | + --public-key=<(curl -s https://<host>/pkg/hex/<owner>/<repo>/public_key) | |
| 7 | + | |
| 8 | + # then, in deps: | |
| 9 | + {:internal_thing, "~> 1.0", repo: "<name>"} | |
| 10 | + | |
| 11 | + Paths below the repository root: | |
| 12 | + | |
| 13 | + /names every package, signed | |
| 14 | + /versions every version of every package, signed | |
| 15 | + /packages/<NAME> one package's releases, signed | |
| 16 | + /tarballs/<NAME>-<VERSION>.tar the published tarball | |
| 17 | + /public_key the key that verifies the above | |
| 18 | + /publish POST a tarball here | |
| 19 | + | |
| 20 | + ## Building the registry rather than rebuilding it | |
| 21 | + | |
| 22 | + The documented way to self-host is `mix hex.registry build`, which | |
| 23 | + regenerates the whole directory from a folder of tarballs. That makes | |
| 24 | + publishing a rebuild, and a rebuild is O(everything) — so this | |
| 25 | + encodes the protobufs on request instead, straight out of Postgres. | |
| 26 | + Publishing becomes an insert. | |
| 27 | + | |
| 28 | + ## The signature | |
| 29 | + | |
| 30 | + Every resource is a `Signed{payload, signature}`, and the signature | |
| 31 | + is an RSA signature over the **SHA-512 digest of the payload**, both | |
| 32 | + unencoded. Then the encoded `Signed` is gzipped — and gzipped as | |
| 33 | + *content*, not as a transfer encoding: `mix` gunzips the body itself, | |
| 34 | + so a `Content-Encoding: gzip` header would have the HTTP client | |
| 35 | + decompress it first and leave `mix` with something it can't read. | |
| 36 | + | |
| 37 | + ## Repository name | |
| 38 | + | |
| 39 | + `mix` refuses a payload whose `repository` field doesn't match the | |
| 40 | + name the repo was added under, so the name isn't cosmetic. It is | |
| 41 | + derived here (`<owner>_<repo>`), and the packages page prints the | |
| 42 | + exact `mix hex.repo add` line to paste. | |
| 43 | + """ | |
| 44 | + | |
| 45 | + @behaviour GitGud.Packages.Format | |
| 46 | + | |
| 47 | + alias GitGud.Packages | |
| 48 | + alias GitGud.Packages.Formats.Hex.Pb | |
| 49 | + alias GitGud.Packages.Formats.Hex.Tarball | |
| 50 | + alias GitGud.Packages.Package | |
| 51 | + alias GitGud.Packages.PackageFile | |
| 52 | + alias GitGud.Repositories.Repository | |
| 53 | + alias GitGud.Repositories.Storage | |
| 54 | + | |
| 55 | + @format "hex" | |
| 56 | + @signed "application/octet-stream" | |
| 57 | + | |
| 58 | + @doc """ | |
| 59 | + The name `mix hex.repo add` must be given for this repository. | |
| 60 | + | |
| 61 | + Underscore-joined rather than hyphen-joined because repository names | |
| 62 | + routinely contain hyphens, and `acme-web-utils` shouldn't be | |
| 63 | + ambiguous about where the owner stops. | |
| 64 | + """ | |
| 65 | + def repo_name(%Repository{} = repo) do | |
| 66 | + repo = GitGud.Repo.preload(repo, [:owner, :organization]) | |
| 67 | + "#{Storage.repo_handle(repo)}_#{repo.name}" | |
| 68 | + end | |
| 69 | + | |
| 70 | + @impl true | |
| 71 | + def content_type(_filename), do: "application/octet-stream" | |
| 72 | + | |
| 73 | + # ── reads ─────────────────────────────────────────────────────────── | |
| 74 | + | |
| 75 | + @impl true | |
| 76 | + def index(repo, ctx) | |
| 77 | + | |
| 78 | + def index(repo, %{path: ["public_key"]}) do | |
| 79 | + with {:ok, %{public_pem: pem}} <- Packages.signing_key(repo, @format) do | |
| 80 | + {:ok, "text/plain; charset=utf-8", pem} | |
| 81 | + end | |
| 82 | + end | |
| 83 | + | |
| 84 | + def index(repo, %{path: ["names"]}) do | |
| 85 | + packages = | |
| 86 | + repo | |
| 87 | + |> Packages.list_packages(@format) | |
| 88 | + |> Enum.map(&%Pb.Names.Package{name: &1.name}) | |
| 89 | + | |
| 90 | + sign(repo, Pb.Names, %Pb.Names{repository: repo_name(repo), packages: packages}) | |
| 91 | + end | |
| 92 | + | |
| 93 | + def index(repo, %{path: ["versions"]}) do | |
| 94 | + packages = | |
| 95 | + repo | |
| 96 | + |> Packages.list_packages(@format) | |
| 97 | + |> Enum.map(fn package -> | |
| 98 | + releases = sorted_versions(package) | |
| 99 | + | |
| 100 | + %Pb.Versions.Package{ | |
| 101 | + name: package.name, | |
| 102 | + versions: Enum.map(releases, & &1.version), | |
| 103 | + # Indexes into `versions`, not version strings — the spec | |
| 104 | + # keeps the list positional. | |
| 105 | + retired: retired_indexes(releases) | |
| 106 | + } | |
| 107 | + end) | |
| 108 | + | |
| 109 | + sign(repo, Pb.Versions, %Pb.Versions{repository: repo_name(repo), packages: packages}) | |
| 110 | + end | |
| 111 | + | |
| 112 | + def index(repo, %{path: ["packages", name]}) do | |
| 113 | + case Packages.get_package(repo, @format, name) do | |
| 114 | + nil -> | |
| 115 | + {:error, :not_found} | |
| 116 | + | |
| 117 | + %Package{} = package -> | |
| 118 | + releases = package |> sorted_versions() |> Enum.map(&release(&1)) | |
| 119 | + | |
| 120 | + sign(repo, Pb.Package, %Pb.Package{ | |
| 121 | + repository: repo_name(repo), | |
| 122 | + name: package.name, | |
| 123 | + releases: releases | |
| 124 | + }) | |
| 125 | + end | |
| 126 | + end | |
| 127 | + | |
| 128 | + def index(repo, %{path: ["tarballs", filename]}) do | |
| 129 | + case find_tarball(repo, filename) do | |
| 130 | + %PackageFile{} = file -> {:blob, file} | |
| 131 | + nil -> {:error, :not_found} | |
| 132 | + end | |
| 133 | + end | |
| 134 | + | |
| 135 | + def index(_repo, _ctx), do: {:error, :not_found} | |
| 136 | + | |
| 137 | + defp find_tarball(repo, filename) do | |
| 138 | + repo | |
| 139 | + |> Packages.list_packages(@format) | |
| 140 | + |> Enum.find_value(fn package -> Packages.get_package_file(package, filename) end) | |
| 141 | + end | |
| 142 | + | |
| 143 | + # Hex resolves against this list directly, so ordering is the | |
| 144 | + # registry's job. Unparseable versions sort last by string rather | |
| 145 | + # than crashing the whole index. | |
| 146 | + defp sorted_versions(%Package{} = package) do | |
| 147 | + package | |
| 148 | + |> Packages.list_versions() | |
| 149 | + |> Enum.sort(fn a, b -> | |
| 150 | + case {Version.parse(a.version), Version.parse(b.version)} do | |
| 151 | + {{:ok, va}, {:ok, vb}} -> Version.compare(va, vb) != :gt | |
| 152 | + {{:ok, _}, :error} -> true | |
| 153 | + {:error, {:ok, _}} -> false | |
| 154 | + _ -> a.version <= b.version | |
| 155 | + end | |
| 156 | + end) | |
| 157 | + end | |
| 158 | + | |
| 159 | + defp retired_indexes(releases) do | |
| 160 | + releases | |
| 161 | + |> Enum.with_index() | |
| 162 | + |> Enum.filter(fn {release, _index} -> release.yanked end) | |
| 163 | + |> Enum.map(fn {_release, index} -> index end) | |
| 164 | + end | |
| 165 | + | |
| 166 | + defp release(version) do | |
| 167 | + metadata = version.metadata || %{} | |
| 168 | + | |
| 169 | + %Pb.Release{ | |
| 170 | + version: version.version, | |
| 171 | + inner_checksum: decode_checksum(metadata["inner_checksum"]), | |
| 172 | + outer_checksum: decode_checksum(metadata["outer_checksum"]), | |
| 173 | + dependencies: Enum.map(metadata["dependencies"] || [], &dependency/1), | |
| 174 | + retired: retirement(version) | |
| 175 | + } | |
| 176 | + end | |
| 177 | + | |
| 178 | + # Checksums travel as hex in the database and as raw bytes on the | |
| 179 | + # wire. | |
| 180 | + defp decode_checksum(nil), do: <<>> | |
| 181 | + | |
| 182 | + defp decode_checksum(hex) when is_binary(hex) do | |
| 183 | + case Base.decode16(hex, case: :mixed) do | |
| 184 | + {:ok, raw} -> raw | |
| 185 | + :error -> <<>> | |
| 186 | + end | |
| 187 | + end | |
| 188 | + | |
| 189 | + defp dependency(dep) when is_map(dep) do | |
| 190 | + %Pb.Dependency{ | |
| 191 | + package: dep["package"] || dep["name"] || "", | |
| 192 | + requirement: dep["requirement"] || "", | |
| 193 | + optional: dep["optional"] || false, | |
| 194 | + app: dep["app"], | |
| 195 | + repository: dep["repository"] | |
| 196 | + } | |
| 197 | + end | |
| 198 | + | |
| 199 | + # Retirement is Hex's yank: the release stays resolvable and `mix` | |
| 200 | + # warns. We only ever record RETIRED_OTHER — the finer reasons are a | |
| 201 | + # publisher's claim, and nothing in our publish path establishes one. | |
| 202 | + defp retirement(%{yanked: true} = version), | |
| 203 | + do: %Pb.RetirementStatus{reason: :RETIRED_OTHER, message: version.yanked_reason} | |
| 204 | + | |
| 205 | + defp retirement(_version), do: nil | |
| 206 | + | |
| 207 | + # ── signing ───────────────────────────────────────────────────────── | |
| 208 | + | |
| 209 | + defp sign(repo, module, message) do | |
| 210 | + with {:ok, %{private_pem: pem}} <- Packages.signing_key(repo, @format) do | |
| 211 | + payload = module.encode(message) | |
| 212 | + | |
| 213 | + signed = | |
| 214 | + Pb.Signed.encode(%Pb.Signed{ | |
| 215 | + payload: payload, | |
| 216 | + signature: rsa_sign(payload, pem) | |
| 217 | + }) | |
| 218 | + | |
| 219 | + {:ok, @signed, :zlib.gzip(signed)} | |
| 220 | + end | |
| 221 | + end | |
| 222 | + | |
| 223 | + # "An RSA signature over the SHA-512 digest of the payload, both | |
| 224 | + # unencoded" — which is exactly what :public_key.sign/3 computes, | |
| 225 | + # digest included. | |
| 226 | + defp rsa_sign(payload, private_pem) do | |
| 227 | + [entry] = :public_key.pem_decode(private_pem) | |
| 228 | + :public_key.sign(payload, :sha512, :public_key.pem_entry_decode(entry)) | |
| 229 | + end | |
| 230 | + | |
| 231 | + @doc """ | |
| 232 | + Verify a signed resource against a public key PEM. Not used to serve | |
| 233 | + anything — it exists so the tests can check what a client will check, | |
| 234 | + rather than checking that we called our own signer. | |
| 235 | + """ | |
| 236 | + def verify(signed_gzip, public_pem) do | |
| 237 | + %Pb.Signed{payload: payload, signature: signature} = | |
| 238 | + signed_gzip |> :zlib.gunzip() |> Pb.Signed.decode() | |
| 239 | + | |
| 240 | + [entry] = :public_key.pem_decode(public_pem) | |
| 241 | + | |
| 242 | + {payload, | |
| 243 | + :public_key.verify(payload, :sha512, signature, :public_key.pem_entry_decode(entry))} | |
| 244 | + end | |
| 245 | + | |
| 246 | + # ── publishing ────────────────────────────────────────────────────── | |
| 247 | + | |
| 248 | + @impl true | |
| 249 | + def store(repo, ctx, body) | |
| 250 | + | |
| 251 | + def store(repo, %{method: "POST", path: path} = ctx, body) | |
| 252 | + when is_binary(body) and (path == ["publish"] or path == []) do | |
| 253 | + with {:ok, meta} <- Tarball.parse(body) do | |
| 254 | + filename = "#{meta.name}-#{meta.version}.tar" | |
| 255 | + | |
| 256 | + attrs = %{ | |
| 257 | + filename: filename, | |
| 258 | + body: body, | |
| 259 | + content_type: content_type(filename), | |
| 260 | + metadata: %{"inner_checksum" => meta.inner_checksum}, | |
| 261 | + version_metadata: %{ | |
| 262 | + "inner_checksum" => meta.inner_checksum, | |
| 263 | + "outer_checksum" => Base.encode16(:crypto.hash(:sha256, body), case: :lower), | |
| 264 | + "dependencies" => meta.dependencies, | |
| 265 | + "app" => meta.app, | |
| 266 | + "description" => meta.description | |
| 267 | + }, | |
| 268 | + uploader: uploader(ctx) | |
| 269 | + } | |
| 270 | + | |
| 271 | + case Packages.publish_file(repo, @format, meta.name, meta.version, attrs) do | |
| 272 | + {:ok, _package, _version, _file} -> | |
| 273 | + {:ok, "application/json", | |
| 274 | + Jason.encode_to_iodata!(%{"name" => meta.name, "version" => meta.version})} | |
| 275 | + | |
| 276 | + {:error, :file_exists} -> | |
| 277 | + {:error, :file_exists} | |
| 278 | + | |
| 279 | + {:error, reason} -> | |
| 280 | + {:error, {400, inspect(reason)}} | |
| 281 | + end | |
| 282 | + end | |
| 283 | + end | |
| 284 | + | |
| 285 | + def store(_repo, %{method: "POST"}, _body), | |
| 286 | + do: {:error, {400, "POST the raw Hex tarball to /publish"}} | |
| 287 | + | |
| 288 | + def store(_repo, %{method: method}, _body), | |
| 289 | + do: {:error, {405, "#{method} is not supported by the Hex registry"}} | |
| 290 | + | |
| 291 | + defp uploader(%{principal: {:user, user}}), do: user | |
| 292 | + defp uploader(_ctx), do: nil | |
| 293 | +end |
added
lib/git_gud/packages/formats/hex/registry.pb.ex
+155
−0
@@ -0,0 +1,155 @@
| 1 | +# Hex registry v2 messages — see priv/proto/hex/registry.proto. | |
| 2 | +# | |
| 3 | +# Written by hand rather than generated: this repo's toolchain has no | |
| 4 | +# protoc, and these definitions are transcribed from the ones compiled | |
| 5 | +# into hex_core, which is what `mix` actually decodes with. Keep the | |
| 6 | +# field numbers and `required:` flags in step with the .proto. | |
| 7 | + | |
| 8 | +defmodule GitGud.Packages.Formats.Hex.Pb.Signed do | |
| 9 | + @moduledoc false | |
| 10 | + | |
| 11 | + use Protobuf, full_name: "hex.Signed", syntax: :proto2 | |
| 12 | + | |
| 13 | + field :payload, 1, required: true, type: :bytes | |
| 14 | + field :signature, 2, optional: true, type: :bytes | |
| 15 | +end | |
| 16 | + | |
| 17 | +defmodule GitGud.Packages.Formats.Hex.Pb.Timestamp do | |
| 18 | + @moduledoc false | |
| 19 | + | |
| 20 | + use Protobuf, full_name: "hex.Timestamp", syntax: :proto2 | |
| 21 | + | |
| 22 | + field :seconds, 1, required: true, type: :int64 | |
| 23 | + field :nanos, 2, required: true, type: :int32 | |
| 24 | +end | |
| 25 | + | |
| 26 | +defmodule GitGud.Packages.Formats.Hex.Pb.RetirementReason do | |
| 27 | + @moduledoc false | |
| 28 | + | |
| 29 | + use Protobuf, enum: true, full_name: "hex.RetirementReason", syntax: :proto2 | |
| 30 | + | |
| 31 | + field :RETIRED_OTHER, 0 | |
| 32 | + field :RETIRED_INVALID, 1 | |
| 33 | + field :RETIRED_SECURITY, 2 | |
| 34 | + field :RETIRED_DEPRECATED, 3 | |
| 35 | + field :RETIRED_RENAMED, 4 | |
| 36 | +end | |
| 37 | + | |
| 38 | +defmodule GitGud.Packages.Formats.Hex.Pb.AdvisorySeverity do | |
| 39 | + @moduledoc false | |
| 40 | + | |
| 41 | + use Protobuf, enum: true, full_name: "hex.AdvisorySeverity", syntax: :proto2 | |
| 42 | + | |
| 43 | + field :SEVERITY_NONE, 0 | |
| 44 | + field :SEVERITY_LOW, 1 | |
| 45 | + field :SEVERITY_MEDIUM, 2 | |
| 46 | + field :SEVERITY_HIGH, 3 | |
| 47 | + field :SEVERITY_CRITICAL, 4 | |
| 48 | +end | |
| 49 | + | |
| 50 | +defmodule GitGud.Packages.Formats.Hex.Pb.Names.Package do | |
| 51 | + @moduledoc false | |
| 52 | + | |
| 53 | + use Protobuf, full_name: "hex.Names.Package", syntax: :proto2 | |
| 54 | + | |
| 55 | + field :name, 1, required: true, type: :string | |
| 56 | + field :updated_at, 3, optional: true, type: GitGud.Packages.Formats.Hex.Pb.Timestamp | |
| 57 | +end | |
| 58 | + | |
| 59 | +defmodule GitGud.Packages.Formats.Hex.Pb.Names do | |
| 60 | + @moduledoc false | |
| 61 | + | |
| 62 | + use Protobuf, full_name: "hex.Names", syntax: :proto2 | |
| 63 | + | |
| 64 | + field :packages, 1, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Names.Package | |
| 65 | + field :repository, 2, required: true, type: :string | |
| 66 | +end | |
| 67 | + | |
| 68 | +defmodule GitGud.Packages.Formats.Hex.Pb.Versions.Package do | |
| 69 | + @moduledoc false | |
| 70 | + | |
| 71 | + use Protobuf, full_name: "hex.Versions.Package", syntax: :proto2 | |
| 72 | + | |
| 73 | + field :name, 1, required: true, type: :string | |
| 74 | + field :versions, 2, repeated: true, type: :string | |
| 75 | + field :retired, 3, repeated: true, type: :int32, packed: true | |
| 76 | + field :with_advisories, 5, repeated: true, type: :int32, packed: true | |
| 77 | +end | |
| 78 | + | |
| 79 | +defmodule GitGud.Packages.Formats.Hex.Pb.Versions do | |
| 80 | + @moduledoc false | |
| 81 | + | |
| 82 | + use Protobuf, full_name: "hex.Versions", syntax: :proto2 | |
| 83 | + | |
| 84 | + field :packages, 1, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Versions.Package | |
| 85 | + field :repository, 2, required: true, type: :string | |
| 86 | +end | |
| 87 | + | |
| 88 | +defmodule GitGud.Packages.Formats.Hex.Pb.RetirementStatus do | |
| 89 | + @moduledoc false | |
| 90 | + | |
| 91 | + use Protobuf, full_name: "hex.RetirementStatus", syntax: :proto2 | |
| 92 | + | |
| 93 | + field :reason, 1, | |
| 94 | + required: true, | |
| 95 | + type: GitGud.Packages.Formats.Hex.Pb.RetirementReason, | |
| 96 | + enum: true | |
| 97 | + | |
| 98 | + field :message, 2, optional: true, type: :string | |
| 99 | +end | |
| 100 | + | |
| 101 | +defmodule GitGud.Packages.Formats.Hex.Pb.Dependency do | |
| 102 | + @moduledoc false | |
| 103 | + | |
| 104 | + use Protobuf, full_name: "hex.Dependency", syntax: :proto2 | |
| 105 | + | |
| 106 | + field :package, 1, required: true, type: :string | |
| 107 | + field :requirement, 2, required: true, type: :string | |
| 108 | + field :optional, 3, optional: true, type: :bool | |
| 109 | + field :app, 4, optional: true, type: :string | |
| 110 | + field :repository, 5, optional: true, type: :string | |
| 111 | +end | |
| 112 | + | |
| 113 | +defmodule GitGud.Packages.Formats.Hex.Pb.SecurityAdvisory do | |
| 114 | + @moduledoc false | |
| 115 | + | |
| 116 | + use Protobuf, full_name: "hex.SecurityAdvisory", syntax: :proto2 | |
| 117 | + | |
| 118 | + field :id, 1, required: true, type: :string | |
| 119 | + field :summary, 2, required: true, type: :string | |
| 120 | + field :html_url, 3, required: true, type: :string | |
| 121 | + | |
| 122 | + field :severity, 4, | |
| 123 | + optional: true, | |
| 124 | + type: GitGud.Packages.Formats.Hex.Pb.AdvisorySeverity, | |
| 125 | + enum: true | |
| 126 | + | |
| 127 | + field :cvss_score, 5, optional: true, type: :float | |
| 128 | + field :api_url, 6, required: true, type: :string | |
| 129 | + field :aliases, 7, repeated: true, type: :string | |
| 130 | +end | |
| 131 | + | |
| 132 | +defmodule GitGud.Packages.Formats.Hex.Pb.Release do | |
| 133 | + @moduledoc false | |
| 134 | + | |
| 135 | + use Protobuf, full_name: "hex.Release", syntax: :proto2 | |
| 136 | + | |
| 137 | + field :version, 1, required: true, type: :string | |
| 138 | + field :inner_checksum, 2, required: true, type: :bytes | |
| 139 | + field :dependencies, 3, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Dependency | |
| 140 | + field :retired, 4, optional: true, type: GitGud.Packages.Formats.Hex.Pb.RetirementStatus | |
| 141 | + field :outer_checksum, 5, optional: true, type: :bytes | |
| 142 | + field :advisory_indexes, 6, repeated: true, type: :uint32 | |
| 143 | + field :published_at, 7, optional: true, type: GitGud.Packages.Formats.Hex.Pb.Timestamp | |
| 144 | +end | |
| 145 | + | |
| 146 | +defmodule GitGud.Packages.Formats.Hex.Pb.Package do | |
| 147 | + @moduledoc false | |
| 148 | + | |
| 149 | + use Protobuf, full_name: "hex.Package", syntax: :proto2 | |
| 150 | + | |
| 151 | + field :releases, 1, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Release | |
| 152 | + field :name, 2, required: true, type: :string | |
| 153 | + field :repository, 3, required: true, type: :string | |
| 154 | + field :advisories, 4, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.SecurityAdvisory | |
| 155 | +end |
added
lib/git_gud/packages/formats/hex/tarball.ex
+178
−0
@@ -0,0 +1,178 @@
| 1 | +defmodule GitGud.Packages.Formats.Hex.Tarball do | |
| 2 | + @moduledoc """ | |
| 3 | + Reads the metadata out of a published Hex tarball. | |
| 4 | + | |
| 5 | + The format is an uncompressed `.tar` holding four members: | |
| 6 | + | |
| 7 | + VERSION the tarball format version ("3") | |
| 8 | + CHECKSUM the inner checksum, hex, uppercase | |
| 9 | + metadata.config Erlang terms — name, version, requirements, … | |
| 10 | + contents.tar.gz the package itself | |
| 11 | + | |
| 12 | + Only the first three are read. The inner checksum is taken from | |
| 13 | + `CHECKSUM` rather than recomputed: that file is what it is for, and | |
| 14 | + recomputing means reimplementing the exact concatenation Hex hashes, | |
| 15 | + which is a thing to get subtly wrong rather than a thing to verify. | |
| 16 | + The outer checksum is just SHA-256 of the whole tarball, which the | |
| 17 | + substrate already computes. | |
| 18 | + | |
| 19 | + `metadata.config` is Erlang term syntax. It is parsed with the | |
| 20 | + scanner and `:erl_parse.parse_term/1` — never `:erl_eval` — so an | |
| 21 | + uploaded file is data and only data. | |
| 22 | + """ | |
| 23 | + | |
| 24 | + @type meta :: %{ | |
| 25 | + name: String.t(), | |
| 26 | + version: String.t(), | |
| 27 | + app: String.t() | nil, | |
| 28 | + description: String.t() | nil, | |
| 29 | + inner_checksum: String.t(), | |
| 30 | + dependencies: [map()] | |
| 31 | + } | |
| 32 | + | |
| 33 | + @doc """ | |
| 34 | + Parse a `.tar`. Returns `{:ok, meta}` or `{:error, {status, message}}` | |
| 35 | + shaped for the registry controller. | |
| 36 | + """ | |
| 37 | + @spec parse(binary()) :: {:ok, meta()} | {:error, {integer(), String.t()}} | |
| 38 | + def parse(tarball) when is_binary(tarball) do | |
| 39 | + with {:ok, members} <- extract(tarball), | |
| 40 | + {:ok, config} <- member(members, "metadata.config"), | |
| 41 | + {:ok, checksum} <- member(members, "CHECKSUM"), | |
| 42 | + {:ok, terms} <- consult(config), | |
| 43 | + {:ok, name} <- fetch(terms, "name"), | |
| 44 | + {:ok, version} <- fetch(terms, "version") do | |
| 45 | + {:ok, | |
| 46 | + %{ | |
| 47 | + name: name, | |
| 48 | + version: version, | |
| 49 | + app: optional(terms, "app"), | |
| 50 | + description: optional(terms, "description"), | |
| 51 | + inner_checksum: checksum |> String.trim() |> String.downcase(), | |
| 52 | + dependencies: requirements(terms) | |
| 53 | + }} | |
| 54 | + end | |
| 55 | + end | |
| 56 | + | |
| 57 | + defp extract(tarball) do | |
| 58 | + case :erl_tar.extract({:binary, tarball}, [:memory]) do | |
| 59 | + {:ok, members} -> | |
| 60 | + {:ok, Map.new(members, fn {name, content} -> {to_string(name), content} end)} | |
| 61 | + | |
| 62 | + {:error, reason} -> | |
| 63 | + {:error, {400, "not a readable tar archive: #{inspect(reason)}"}} | |
| 64 | + end | |
| 65 | + end | |
| 66 | + | |
| 67 | + defp member(members, name) do | |
| 68 | + case Map.fetch(members, name) do | |
| 69 | + {:ok, content} -> {:ok, content} | |
| 70 | + :error -> {:error, {400, "the tarball has no #{name} member"}} | |
| 71 | + end | |
| 72 | + end | |
| 73 | + | |
| 74 | + # ── Erlang term config ────────────────────────────────────────────── | |
| 75 | + | |
| 76 | + # `:file.consult/1` over a binary, without the temp file: scan the | |
| 77 | + # whole thing once, cut it at each `.`, and parse each form as a | |
| 78 | + # term. `:erl_parse.parse_term/1` cannot evaluate anything, which is | |
| 79 | + # the point — this input arrived over HTTP. | |
| 80 | + defp consult(binary) do | |
| 81 | + case :erl_scan.string(String.to_charlist(binary)) do | |
| 82 | + {:ok, tokens, _} -> | |
| 83 | + tokens | |
| 84 | + |> forms() | |
| 85 | + |> Enum.reduce_while({:ok, []}, fn form, {:ok, acc} -> | |
| 86 | + case :erl_parse.parse_term(form) do | |
| 87 | + {:ok, term} -> {:cont, {:ok, [term | acc]}} | |
| 88 | + {:error, _} -> {:halt, {:error, {400, "metadata.config is not readable"}}} | |
| 89 | + end | |
| 90 | + end) | |
| 91 | + |> case do | |
| 92 | + {:ok, terms} -> {:ok, Enum.reverse(terms)} | |
| 93 | + err -> err | |
| 94 | + end | |
| 95 | + | |
| 96 | + {:error, _, _} -> | |
| 97 | + {:error, {400, "metadata.config is not readable"}} | |
| 98 | + end | |
| 99 | + end | |
| 100 | + | |
| 101 | + defp forms(tokens) do | |
| 102 | + tokens | |
| 103 | + |> Enum.chunk_while( | |
| 104 | + [], | |
| 105 | + fn | |
| 106 | + {:dot, _} = dot, acc -> {:cont, Enum.reverse([dot | acc]), []} | |
| 107 | + token, acc -> {:cont, [token | acc]} | |
| 108 | + end, | |
| 109 | + fn | |
| 110 | + [] -> {:cont, []} | |
| 111 | + acc -> {:cont, Enum.reverse(acc), []} | |
| 112 | + end | |
| 113 | + ) | |
| 114 | + |> Enum.reject(&(&1 == [])) | |
| 115 | + end | |
| 116 | + | |
| 117 | + defp fetch(terms, key) do | |
| 118 | + case optional(terms, key) do | |
| 119 | + nil -> {:error, {400, "metadata.config has no #{key}"}} | |
| 120 | + value -> {:ok, value} | |
| 121 | + end | |
| 122 | + end | |
| 123 | + | |
| 124 | + defp optional(terms, key) do | |
| 125 | + Enum.find_value(terms, fn | |
| 126 | + {k, value} -> if to_string(k) == key, do: to_string(value) | |
| 127 | + _ -> nil | |
| 128 | + end) | |
| 129 | + end | |
| 130 | + | |
| 131 | + # Requirements are a list of proplists in current Hex, and a proplist | |
| 132 | + # keyed by package name in older tarballs. Both still turn up in a | |
| 133 | + # `deps.get` cache, so both are read. | |
| 134 | + defp requirements(terms) do | |
| 135 | + case Enum.find_value(terms, fn | |
| 136 | + {k, value} -> if to_string(k) == "requirements", do: value | |
| 137 | + _ -> nil | |
| 138 | + end) do | |
| 139 | + nil -> [] | |
| 140 | + list when is_list(list) -> Enum.map(list, &requirement/1) | |
| 141 | + _ -> [] | |
| 142 | + end | |
| 143 | + end | |
| 144 | + | |
| 145 | + # The legacy shape keys each proplist by the package name, and the | |
| 146 | + # inner proplist has no "name" of its own — so the key is the only | |
| 147 | + # place the dependency's name appears. | |
| 148 | + defp requirement({name, props}) when is_list(props) do | |
| 149 | + case requirement(props) do | |
| 150 | + %{"package" => ""} = parsed -> %{parsed | "package" => to_string(name)} | |
| 151 | + parsed -> parsed | |
| 152 | + end | |
| 153 | + end | |
| 154 | + | |
| 155 | + defp requirement(props) when is_list(props) do | |
| 156 | + fields = | |
| 157 | + Enum.reduce(props, %{}, fn | |
| 158 | + {k, v}, acc -> Map.put(acc, to_string(k), normalize(v)) | |
| 159 | + _, acc -> acc | |
| 160 | + end) | |
| 161 | + | |
| 162 | + %{ | |
| 163 | + "package" => fields["name"] || fields["package"] || "", | |
| 164 | + "requirement" => fields["requirement"] || "", | |
| 165 | + "optional" => fields["optional"] == true, | |
| 166 | + "app" => fields["app"], | |
| 167 | + "repository" => fields["repository"] | |
| 168 | + } | |
| 169 | + end | |
| 170 | + | |
| 171 | + defp requirement(_), do: %{"package" => "", "requirement" => "", "optional" => false} | |
| 172 | + | |
| 173 | + defp normalize(value) when is_binary(value), do: value | |
| 174 | + defp normalize(value) when is_boolean(value), do: value | |
| 175 | + defp normalize(value) when is_atom(value), do: to_string(value) | |
| 176 | + defp normalize(value) when is_list(value), do: to_string(value) | |
| 177 | + defp normalize(value), do: value | |
| 178 | +end |
added
lib/git_gud/packages/signing_key.ex
+51
−0
@@ -0,0 +1,51 @@
| 1 | +defmodule GitGud.Packages.SigningKey do | |
| 2 | + @moduledoc """ | |
| 3 | + A repository's keypair for one registry format. | |
| 4 | + | |
| 5 | + The public half is served to clients verbatim — it is what a consumer | |
| 6 | + pins with `mix hex.repo add --public-key` — and the private half is | |
| 7 | + encrypted at rest with the `GitGud.Secrets` master key. Rows keep the | |
| 8 | + `key_id` they were wrapped under so a master-key rotation can re-wrap | |
| 9 | + them the same way secret rows are. | |
| 10 | + """ | |
| 11 | + use Ecto.Schema | |
| 12 | + | |
| 13 | + import Ecto.Changeset | |
| 14 | + | |
| 15 | + alias GitGud.Repositories.Repository | |
| 16 | + | |
| 17 | + schema "package_signing_keys" do | |
| 18 | + field :format, :string | |
| 19 | + field :public_key, :string | |
| 20 | + field :private_key_ciphertext, :binary, redact: true | |
| 21 | + field :private_key_iv, :binary, redact: true | |
| 22 | + field :private_key_tag, :binary, redact: true | |
| 23 | + field :key_id, :string | |
| 24 | + | |
| 25 | + belongs_to :repository, Repository | |
| 26 | + | |
| 27 | + timestamps(type: :utc_datetime) | |
| 28 | + end | |
| 29 | + | |
| 30 | + @doc false | |
| 31 | + def changeset(key, attrs) do | |
| 32 | + key | |
| 33 | + |> cast(attrs, [ | |
| 34 | + :format, | |
| 35 | + :public_key, | |
| 36 | + :private_key_ciphertext, | |
| 37 | + :private_key_iv, | |
| 38 | + :private_key_tag, | |
| 39 | + :key_id | |
| 40 | + ]) | |
| 41 | + |> validate_required([ | |
| 42 | + :format, | |
| 43 | + :public_key, | |
| 44 | + :private_key_ciphertext, | |
| 45 | + :private_key_iv, | |
| 46 | + :private_key_tag, | |
| 47 | + :key_id | |
| 48 | + ]) | |
| 49 | + |> unique_constraint([:repository_id, :format]) | |
| 50 | + end | |
| 51 | +end |
modified
lib/git_gud_web/live/repo_live/packages.ex
+16
−1
@@ -51,7 +51,10 @@ defmodule GitGudWeb.RepoLive.Packages do
| 51 | 51 | # The OCI registry is a separate host (zot); everything else is |
| 52 | 52 | # served by this forge. |
| 53 | 53 | host: Application.get_env(:git_gud, :registry_host), |
| 54 | − root: registry_root() | |
| 54 | + root: registry_root(), | |
| 55 | + # The name `mix hex.repo add` must use; Hex checks it against | |
| 56 | + # the registry payload. | |
| 57 | + hex_repo: GitGud.Packages.Formats.Hex.repo_name(repo) | |
| 55 | 58 | }) |
| 56 | 59 | |> assign(:page_title, "Packages — #{owner}/#{name}")} |
| 57 | 60 | end |
@@ -194,6 +197,14 @@ defmodule GitGudWeb.RepoLive.Packages do
| 194 | 197 | defp configure_line("pypi", %{handle: handle, repo_name: name, root: root}), |
| 195 | 198 | do: "pip config set global.index-url #{root}/pkg/pypi/#{handle}/#{name}/simple/" |
| 196 | 199 | |
| 200 | + # Hex verifies the registry signature against a pinned public key and | |
| 201 | + # refuses a payload whose repository name doesn't match the one it | |
| 202 | + # was added under — so both halves of this line are load-bearing. | |
| 203 | + defp configure_line("hex", %{handle: handle, repo_name: name, root: root, hex_repo: hex_repo}) do | |
| 204 | + url = "#{root}/pkg/hex/#{handle}/#{name}" | |
| 205 | + "mix hex.repo add #{hex_repo} #{url} --public-key=<(curl -s #{url}/public_key)" | |
| 206 | + end | |
| 207 | + | |
| 197 | 208 | defp configure_line(_format, _registry), do: nil |
| 198 | 209 | |
| 199 | 210 | # Reconstruct the zot repository path the webhook deflated: a package |
@@ -210,6 +221,10 @@ defmodule GitGudWeb.RepoLive.Packages do
| 210 | 221 | "pip install --index-url #{root}/pkg/pypi/#{handle}/#{name}/simple/ #{pkg.name}==#{version}" |
| 211 | 222 | end |
| 212 | 223 | |
| 224 | + defp install_line("hex", %{hex_repo: hex_repo}, pkg, version) do | |
| 225 | + ~s[{:#{pkg.name}, "~> #{version}", repo: "#{hex_repo}"}] | |
| 226 | + end | |
| 227 | + | |
| 213 | 228 | defp install_line(_format, _registry, _pkg, _version), do: nil |
| 214 | 229 | |
| 215 | 230 | defp short_digest("sha256:" <> rest), |
added
priv/proto/hex/registry.proto
+104
−0
@@ -0,0 +1,104 @@
| 1 | +// Hex registry v2 — https://github.com/hexpm/specifications/blob/main/registry-v2.md | |
| 2 | +// | |
| 3 | +// Transcribed from the message definitions compiled into hex_core | |
| 4 | +// (mix_hex_pb_names, _versions, _package, _signed), which is the | |
| 5 | +// authority `mix` itself decodes with. proto2: several fields are | |
| 6 | +// `required`, and Hex's own encoder relies on that. | |
| 7 | +// | |
| 8 | +// There is no protoc in this repo's toolchain, so the Elixir modules | |
| 9 | +// in lib/git_gud/packages/formats/hex/registry.pb.ex are written by | |
| 10 | +// hand to match. This file is the source of truth for what they mean. | |
| 11 | + | |
| 12 | +syntax = "proto2"; | |
| 13 | + | |
| 14 | +package hex; | |
| 15 | + | |
| 16 | +message Signed { | |
| 17 | + required bytes payload = 1; | |
| 18 | + optional bytes signature = 2; | |
| 19 | +} | |
| 20 | + | |
| 21 | +message Timestamp { | |
| 22 | + required int64 seconds = 1; | |
| 23 | + required int32 nanos = 2; | |
| 24 | +} | |
| 25 | + | |
| 26 | +message Names { | |
| 27 | + repeated Package packages = 1; | |
| 28 | + required string repository = 2; | |
| 29 | + | |
| 30 | + message Package { | |
| 31 | + required string name = 1; | |
| 32 | + optional Timestamp updated_at = 3; | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +message Versions { | |
| 37 | + repeated Package packages = 1; | |
| 38 | + required string repository = 2; | |
| 39 | + | |
| 40 | + message Package { | |
| 41 | + required string name = 1; | |
| 42 | + repeated string versions = 2; | |
| 43 | + repeated int32 retired = 3 [packed = true]; | |
| 44 | + repeated int32 with_advisories = 5 [packed = true]; | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +enum RetirementReason { | |
| 49 | + RETIRED_OTHER = 0; | |
| 50 | + RETIRED_INVALID = 1; | |
| 51 | + RETIRED_SECURITY = 2; | |
| 52 | + RETIRED_DEPRECATED = 3; | |
| 53 | + RETIRED_RENAMED = 4; | |
| 54 | +} | |
| 55 | + | |
| 56 | +enum AdvisorySeverity { | |
| 57 | + SEVERITY_NONE = 0; | |
| 58 | + SEVERITY_LOW = 1; | |
| 59 | + SEVERITY_MEDIUM = 2; | |
| 60 | + SEVERITY_HIGH = 3; | |
| 61 | + SEVERITY_CRITICAL = 4; | |
| 62 | +} | |
| 63 | + | |
| 64 | +message RetirementStatus { | |
| 65 | + required RetirementReason reason = 1; | |
| 66 | + optional string message = 2; | |
| 67 | +} | |
| 68 | + | |
| 69 | +message Dependency { | |
| 70 | + required string package = 1; | |
| 71 | + required string requirement = 2; | |
| 72 | + optional bool optional = 3; | |
| 73 | + optional string app = 4; | |
| 74 | + optional string repository = 5; | |
| 75 | +} | |
| 76 | + | |
| 77 | +message SecurityAdvisory { | |
| 78 | + required string id = 1; | |
| 79 | + required string summary = 2; | |
| 80 | + required string html_url = 3; | |
| 81 | + optional AdvisorySeverity severity = 4; | |
| 82 | + optional float cvss_score = 5; | |
| 83 | + required string api_url = 6; | |
| 84 | + repeated string aliases = 7; | |
| 85 | +} | |
| 86 | + | |
| 87 | +message Release { | |
| 88 | + required string version = 1; | |
| 89 | + // SHA-256 of the *inner* tarball (contents.tar.gz). | |
| 90 | + required bytes inner_checksum = 2; | |
| 91 | + repeated Dependency dependencies = 3; | |
| 92 | + optional RetirementStatus retired = 4; | |
| 93 | + // SHA-256 of the whole published .tar. | |
| 94 | + optional bytes outer_checksum = 5; | |
| 95 | + repeated uint32 advisory_indexes = 6; | |
| 96 | + optional Timestamp published_at = 7; | |
| 97 | +} | |
| 98 | + | |
| 99 | +message Package { | |
| 100 | + repeated Release releases = 1; | |
| 101 | + required string name = 2; | |
| 102 | + required string repository = 3; | |
| 103 | + repeated SecurityAdvisory advisories = 4; | |
| 104 | +} |
added
priv/repo/migrations/20260911010000_create_package_signing_keys.exs
+37
−0
@@ -0,0 +1,37 @@
| 1 | +defmodule GitGud.Repo.Migrations.CreatePackageSigningKeys do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + @moduledoc """ | |
| 5 | + Per-repository, per-format signing keys for the package registries. | |
| 6 | + | |
| 7 | + Hex requires every registry resource to be signed, and apt requires a | |
| 8 | + signed `Release`. Both want a keypair whose public half a client | |
| 9 | + pins once (`mix hex.repo add --public-key`, `/etc/apt/keyrings`) and | |
| 10 | + whose private half never leaves the server — so the private key is | |
| 11 | + encrypted at rest with the same AES-256-GCM master key | |
| 12 | + `GitGud.Secrets` uses, and carries the same `key_id` so a rotation | |
| 13 | + re-wrap reaches it too. | |
| 14 | + | |
| 15 | + Per repository rather than per forge: the public key is the thing a | |
| 16 | + consumer pins, and one shared key would mean rotating it breaks every | |
| 17 | + repository at once. | |
| 18 | + """ | |
| 19 | + | |
| 20 | + def change do | |
| 21 | + create table(:package_signing_keys) do | |
| 22 | + add :repository_id, references(:repositories, on_delete: :delete_all), null: false | |
| 23 | + add :format, :string, null: false, size: 20 | |
| 24 | + # Public half, PEM, served verbatim to clients. | |
| 25 | + add :public_key, :text, null: false | |
| 26 | + # Private half, AES-256-GCM under the GitGud.Secrets master key. | |
| 27 | + add :private_key_ciphertext, :binary, null: false | |
| 28 | + add :private_key_iv, :binary, null: false | |
| 29 | + add :private_key_tag, :binary, null: false | |
| 30 | + add :key_id, :string, null: false | |
| 31 | + | |
| 32 | + timestamps(type: :utc_datetime) | |
| 33 | + end | |
| 34 | + | |
| 35 | + create unique_index(:package_signing_keys, [:repository_id, :format]) | |
| 36 | + end | |
| 37 | +end |
added
test/git_gud_web/hex_registry_test.exs
+380
−0
@@ -0,0 +1,380 @@
| 1 | +defmodule GitGudWeb.HexRegistryTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Phase 2: the Hex registry v2 repository, over HTTP. | |
| 4 | + | |
| 5 | + The assertions that matter here are the ones `mix` makes: the | |
| 6 | + payload decodes, the signature verifies against the published public | |
| 7 | + key, and the `repository` field matches the name the repo was added | |
| 8 | + under — `mix` rejects the registry outright if any of those is off. | |
| 9 | + """ | |
| 10 | + use GitGudWeb.ConnCase, async: false | |
| 11 | + | |
| 12 | + alias GitGud.Packages | |
| 13 | + alias GitGud.Packages.Formats.Hex, as: HexFormat | |
| 14 | + alias GitGud.Packages.Formats.Hex.Pb | |
| 15 | + alias GitGud.Packages.Formats.Hex.Tarball | |
| 16 | + alias GitGud.Repositories.Storage | |
| 17 | + | |
| 18 | + import GitGud.ForgeFixtures | |
| 19 | + | |
| 20 | + setup do | |
| 21 | + {user, repo} = repository_fixture(%{visibility: "public"}) | |
| 22 | + {:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"]) | |
| 23 | + | |
| 24 | + {:ok, | |
| 25 | + user: user, | |
| 26 | + repo: repo, | |
| 27 | + token: token, | |
| 28 | + root: "/pkg/hex/#{Storage.owner_handle(user)}/#{repo.name}"} | |
| 29 | + end | |
| 30 | + | |
| 31 | + # A published Hex tarball: an uncompressed .tar of VERSION, CHECKSUM, | |
| 32 | + # metadata.config and contents.tar.gz. | |
| 33 | + defp hex_tarball(name, version, opts \\ []) do | |
| 34 | + requirements = Keyword.get(opts, :requirements, []) | |
| 35 | + contents = :zlib.gzip("the package source") | |
| 36 | + | |
| 37 | + config = | |
| 38 | + [ | |
| 39 | + ~s({<<"name">>, <<"#{name}">>}.), | |
| 40 | + ~s({<<"version">>, <<"#{version}">>}.), | |
| 41 | + ~s({<<"app">>, <<"#{name}">>}.), | |
| 42 | + ~s({<<"description">>, <<"a test package">>}.), | |
| 43 | + requirements_term(requirements) | |
| 44 | + ] | |
| 45 | + |> Enum.join("\n") | |
| 46 | + | |
| 47 | + inner = :crypto.hash(:sha256, "3" <> config <> contents) | |
| 48 | + | |
| 49 | + members = [ | |
| 50 | + {~c"VERSION", "3"}, | |
| 51 | + {~c"CHECKSUM", Base.encode16(inner, case: :upper)}, | |
| 52 | + {~c"metadata.config", config}, | |
| 53 | + {~c"contents.tar.gz", contents} | |
| 54 | + ] | |
| 55 | + | |
| 56 | + path = Path.join(System.tmp_dir!(), "hex-#{System.unique_integer([:positive])}.tar") | |
| 57 | + :ok = :erl_tar.create(String.to_charlist(path), members, []) | |
| 58 | + File.read!(path) | |
| 59 | + end | |
| 60 | + | |
| 61 | + defp requirements_term([]), do: ~s({<<"requirements">>, []}.) | |
| 62 | + | |
| 63 | + defp requirements_term(reqs) do | |
| 64 | + entries = | |
| 65 | + Enum.map_join(reqs, ",", fn {pkg, req} -> | |
| 66 | + ~s([{<<"name">>,<<"#{pkg}">>},{<<"app">>,<<"#{pkg}">>},) <> | |
| 67 | + ~s({<<"optional">>,false},{<<"requirement">>,<<"#{req}">>},) <> | |
| 68 | + ~s({<<"repository">>,<<"hexpm">>}]) | |
| 69 | + end) | |
| 70 | + | |
| 71 | + ~s({<<"requirements">>, [#{entries}]}.) | |
| 72 | + end | |
| 73 | + | |
| 74 | + defp publish(conn, root, token, tarball) do | |
| 75 | + conn | |
| 76 | + |> put_req_header("authorization", token) | |
| 77 | + |> put_req_header("content-type", "application/octet-stream") | |
| 78 | + |> post(root <> "/publish", tarball) | |
| 79 | + end | |
| 80 | + | |
| 81 | + defp public_key(conn, root) do | |
| 82 | + get(conn, root <> "/public_key").resp_body | |
| 83 | + end | |
| 84 | + | |
| 85 | + # Exactly what a client does: gunzip, decode Signed, verify the RSA | |
| 86 | + # signature over the SHA-512 digest of the payload, then decode. | |
| 87 | + defp fetch_signed(conn, root, path, module, message) do | |
| 88 | + pem = public_key(build_conn(), root) | |
| 89 | + body = get(conn, root <> path).resp_body | |
| 90 | + | |
| 91 | + {payload, verified?} = HexFormat.verify(body, pem) | |
| 92 | + assert verified?, "#{path} did not verify against the published public key" | |
| 93 | + | |
| 94 | + {module.decode(payload), message} | |
| 95 | + |> elem(0) | |
| 96 | + end | |
| 97 | + | |
| 98 | + describe "/public_key" do | |
| 99 | + test "serves a PEM the registry's own signatures verify against", %{conn: conn, root: root} do | |
| 100 | + pem = public_key(conn, root) | |
| 101 | + | |
| 102 | + assert pem =~ "-----BEGIN PUBLIC KEY-----" | |
| 103 | + assert [{:SubjectPublicKeyInfo, _, _}] = :public_key.pem_decode(pem) | |
| 104 | + end | |
| 105 | + | |
| 106 | + test "is stable across requests — a rotating key breaks every consumer", %{ | |
| 107 | + conn: conn, | |
| 108 | + root: root | |
| 109 | + } do | |
| 110 | + assert public_key(conn, root) == public_key(build_conn(), root) | |
| 111 | + end | |
| 112 | + end | |
| 113 | + | |
| 114 | + describe "/names" do | |
| 115 | + test "lists published packages, signed", %{conn: conn, repo: repo, root: root, token: token} do | |
| 116 | + publish(conn, root, token, hex_tarball("widget", "1.0.0")) | |
| 117 | + | |
| 118 | + names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names) | |
| 119 | + | |
| 120 | + assert names.repository == HexFormat.repo_name(repo) | |
| 121 | + assert Enum.map(names.packages, & &1.name) == ["widget"] | |
| 122 | + end | |
| 123 | + | |
| 124 | + test "the repository field matches the name mix must add it under", %{ | |
| 125 | + conn: conn, | |
| 126 | + repo: repo, | |
| 127 | + root: root, | |
| 128 | + token: token | |
| 129 | + } do | |
| 130 | + publish(conn, root, token, hex_tarball("widget", "1.0.0")) | |
| 131 | + | |
| 132 | + names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names) | |
| 133 | + | |
| 134 | + # `mix` rejects the whole registry when these disagree. | |
| 135 | + assert names.repository == HexFormat.repo_name(repo) | |
| 136 | + assert names.repository =~ repo.name | |
| 137 | + end | |
| 138 | + | |
| 139 | + test "a tampered payload fails verification", %{conn: conn, root: root, token: token} do | |
| 140 | + publish(conn, root, token, hex_tarball("widget", "1.0.0")) | |
| 141 | + | |
| 142 | + pem = public_key(build_conn(), root) | |
| 143 | + | |
| 144 | + %Pb.Signed{} = | |
| 145 | + signed = | |
| 146 | + get(build_conn(), root <> "/names").resp_body |> :zlib.gunzip() |> Pb.Signed.decode() | |
| 147 | + | |
| 148 | + forged = | |
| 149 | + %Pb.Signed{signed | payload: Pb.Names.encode(%Pb.Names{repository: "evil", packages: []})} | |
| 150 | + |> Pb.Signed.encode() | |
| 151 | + |> :zlib.gzip() | |
| 152 | + | |
| 153 | + assert {_payload, false} = HexFormat.verify(forged, pem) | |
| 154 | + end | |
| 155 | + end | |
| 156 | + | |
| 157 | + describe "/versions" do | |
| 158 | + test "lists versions in order with retired entries by index", %{ | |
| 159 | + conn: conn, | |
| 160 | + repo: repo, | |
| 161 | + root: root, | |
| 162 | + token: token | |
| 163 | + } do | |
| 164 | + for v <- ["1.0.0", "1.2.0", "1.10.0"] do | |
| 165 | + publish(build_conn(), root, token, hex_tarball("widget", v)) | |
| 166 | + end | |
| 167 | + | |
| 168 | + pkg = Packages.get_package(repo, "hex", "widget") | |
| 169 | + {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.2.0"), true, "bad build") | |
| 170 | + | |
| 171 | + versions = fetch_signed(conn, root, "/versions", Pb.Versions, Pb.Versions) | |
| 172 | + | |
| 173 | + assert [package] = versions.packages | |
| 174 | + # Semver order, not lexicographic — 1.10.0 sorts after 1.2.0. | |
| 175 | + assert package.versions == ["1.0.0", "1.2.0", "1.10.0"] | |
| 176 | + # Positional: retirement points at an index in `versions`. | |
| 177 | + assert package.retired == [1] | |
| 178 | + end | |
| 179 | + end | |
| 180 | + | |
| 181 | + describe "/packages/<name>" do | |
| 182 | + test "carries checksums and dependencies", %{conn: conn, root: root, token: token} do | |
| 183 | + tarball = hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}]) | |
| 184 | + publish(conn, root, token, tarball) | |
| 185 | + | |
| 186 | + package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package) | |
| 187 | + | |
| 188 | + assert [release] = package.releases | |
| 189 | + assert release.version == "1.0.0" | |
| 190 | + | |
| 191 | + # Checksums go out as raw bytes, not hex. | |
| 192 | + assert byte_size(release.inner_checksum) == 32 | |
| 193 | + assert release.outer_checksum == :crypto.hash(:sha256, tarball) | |
| 194 | + | |
| 195 | + assert [dep] = release.dependencies | |
| 196 | + assert dep.package == "jason" | |
| 197 | + assert dep.requirement == "~> 1.0" | |
| 198 | + refute dep.optional | |
| 199 | + end | |
| 200 | + | |
| 201 | + test "a yanked release is retired, not removed", %{ | |
| 202 | + conn: conn, | |
| 203 | + repo: repo, | |
| 204 | + root: root, | |
| 205 | + token: token | |
| 206 | + } do | |
| 207 | + publish(conn, root, token, hex_tarball("widget", "1.0.0")) | |
| 208 | + pkg = Packages.get_package(repo, "hex", "widget") | |
| 209 | + {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.0.0"), true, "bad build") | |
| 210 | + | |
| 211 | + package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package) | |
| 212 | + | |
| 213 | + assert [%Pb.Release{retired: retired}] = package.releases | |
| 214 | + assert retired.reason == :RETIRED_OTHER | |
| 215 | + assert retired.message == "bad build" | |
| 216 | + end | |
| 217 | + | |
| 218 | + test "404 for an unpublished package", %{conn: conn, root: root} do | |
| 219 | + assert get(conn, root <> "/packages/nothing").status == 404 | |
| 220 | + end | |
| 221 | + end | |
| 222 | + | |
| 223 | + describe "/tarballs" do | |
| 224 | + test "serves the published bytes back verbatim", %{conn: conn, root: root, token: token} do | |
| 225 | + tarball = hex_tarball("widget", "1.0.0") | |
| 226 | + publish(conn, root, token, tarball) | |
| 227 | + | |
| 228 | + conn = get(build_conn(), root <> "/tarballs/widget-1.0.0.tar") | |
| 229 | + | |
| 230 | + assert conn.status == 200 | |
| 231 | + assert conn.resp_body == tarball | |
| 232 | + end | |
| 233 | + end | |
| 234 | + | |
| 235 | + describe "publishing" do | |
| 236 | + test "derives name, version and dependencies from the tarball", %{ | |
| 237 | + conn: conn, | |
| 238 | + repo: repo, | |
| 239 | + root: root, | |
| 240 | + token: token | |
| 241 | + } do | |
| 242 | + conn = | |
| 243 | + publish( | |
| 244 | + conn, | |
| 245 | + root, | |
| 246 | + token, | |
| 247 | + hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}]) | |
| 248 | + ) | |
| 249 | + | |
| 250 | + assert conn.status == 200 | |
| 251 | + assert %{"name" => "widget", "version" => "1.0.0"} = Jason.decode!(conn.resp_body) | |
| 252 | + | |
| 253 | + pkg = Packages.get_package(repo, "hex", "widget") | |
| 254 | + version = Packages.get_version(pkg, "1.0.0") | |
| 255 | + | |
| 256 | + assert [%{"package" => "jason", "requirement" => "~> 1.0"}] = | |
| 257 | + version.metadata["dependencies"] | |
| 258 | + end | |
| 259 | + | |
| 260 | + test "rejects something that isn't a tar", %{conn: conn, root: root, token: token} do | |
| 261 | + conn = publish(conn, root, token, "definitely not a tarball") | |
| 262 | + | |
| 263 | + assert conn.status == 400 | |
| 264 | + assert conn.resp_body =~ "tar" | |
| 265 | + end | |
| 266 | + | |
| 267 | + test "rejects a tar with no metadata.config", %{conn: conn, root: root, token: token} do | |
| 268 | + path = Path.join(System.tmp_dir!(), "empty-#{System.unique_integer([:positive])}.tar") | |
| 269 | + :ok = :erl_tar.create(String.to_charlist(path), [{~c"VERSION", "3"}], []) | |
| 270 | + | |
| 271 | + conn = publish(conn, root, token, File.read!(path)) | |
| 272 | + | |
| 273 | + assert conn.status == 400 | |
| 274 | + assert conn.resp_body =~ "metadata.config" | |
| 275 | + end | |
| 276 | + | |
| 277 | + test "an anonymous publish is refused", %{conn: conn, root: root} do | |
| 278 | + conn = | |
| 279 | + conn | |
| 280 | + |> put_req_header("content-type", "application/octet-stream") | |
| 281 | + |> post(root <> "/publish", hex_tarball("widget", "1.0.0")) | |
| 282 | + | |
| 283 | + assert conn.status == 401 | |
| 284 | + end | |
| 285 | + | |
| 286 | + test "409 on republishing a version with different bytes", %{ | |
| 287 | + conn: conn, | |
| 288 | + root: root, | |
| 289 | + token: token | |
| 290 | + } do | |
| 291 | + publish(conn, root, token, hex_tarball("widget", "1.0.0")) | |
| 292 | + | |
| 293 | + conn = | |
| 294 | + publish( | |
| 295 | + build_conn(), | |
| 296 | + root, | |
| 297 | + token, | |
| 298 | + hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}]) | |
| 299 | + ) | |
| 300 | + | |
| 301 | + assert conn.status == 409 | |
| 302 | + end | |
| 303 | + end | |
| 304 | + | |
| 305 | + describe "Tarball.parse/1" do | |
| 306 | + test "reads the inner checksum out of CHECKSUM rather than recomputing it" do | |
| 307 | + tarball = hex_tarball("widget", "1.0.0") | |
| 308 | + | |
| 309 | + assert {:ok, meta} = Tarball.parse(tarball) | |
| 310 | + assert meta.name == "widget" | |
| 311 | + assert meta.version == "1.0.0" | |
| 312 | + assert meta.app == "widget" | |
| 313 | + assert meta.description == "a test package" | |
| 314 | + assert String.match?(meta.inner_checksum, ~r/\A[0-9a-f]{64}\z/) | |
| 315 | + end | |
| 316 | + | |
| 317 | + test "reads the older proplist-keyed requirements shape too" do | |
| 318 | + contents = :zlib.gzip("source") | |
| 319 | + | |
| 320 | + config = """ | |
| 321 | + {<<"name">>, <<"legacy">>}. | |
| 322 | + {<<"version">>, <<"0.1.0">>}. | |
| 323 | + {<<"requirements">>, [{<<"jason">>, [{<<"requirement">>, <<"~> 1.0">>}, {<<"optional">>, false}]}]}. | |
| 324 | + """ | |
| 325 | + | |
| 326 | + inner = :crypto.hash(:sha256, "3" <> config <> contents) | |
| 327 | + | |
| 328 | + path = Path.join(System.tmp_dir!(), "legacy-#{System.unique_integer([:positive])}.tar") | |
| 329 | + | |
| 330 | + :ok = | |
| 331 | + :erl_tar.create( | |
| 332 | + String.to_charlist(path), | |
| 333 | + [ | |
| 334 | + {~c"VERSION", "3"}, | |
| 335 | + {~c"CHECKSUM", Base.encode16(inner, case: :upper)}, | |
| 336 | + {~c"metadata.config", config}, | |
| 337 | + {~c"contents.tar.gz", contents} | |
| 338 | + ], | |
| 339 | + [] | |
| 340 | + ) | |
| 341 | + | |
| 342 | + assert {:ok, meta} = Tarball.parse(File.read!(path)) | |
| 343 | + assert [%{"package" => "jason", "requirement" => "~> 1.0"}] = meta.dependencies | |
| 344 | + end | |
| 345 | + | |
| 346 | + test "metadata.config is parsed as data — a function call is not evaluated" do | |
| 347 | + contents = :zlib.gzip("source") | |
| 348 | + config = "{<<\"name\">>, os:cmd(\"touch /tmp/pwned\")}.\n" | |
| 349 | + inner = :crypto.hash(:sha256, "3" <> config <> contents) | |
| 350 | + | |
| 351 | + path = Path.join(System.tmp_dir!(), "evil-#{System.unique_integer([:positive])}.tar") | |
| 352 | + | |
| 353 | + :ok = | |
| 354 | + :erl_tar.create( | |
| 355 | + String.to_charlist(path), | |
| 356 | + [ | |
| 357 | + {~c"VERSION", "3"}, | |
| 358 | + {~c"CHECKSUM", Base.encode16(inner, case: :upper)}, | |
| 359 | + {~c"metadata.config", config}, | |
| 360 | + {~c"contents.tar.gz", contents} | |
| 361 | + ], | |
| 362 | + [] | |
| 363 | + ) | |
| 364 | + | |
| 365 | + assert {:error, {400, _}} = Tarball.parse(File.read!(path)) | |
| 366 | + end | |
| 367 | + end | |
| 368 | + | |
| 369 | + describe "visibility" do | |
| 370 | + test "a private registry challenges an anonymous client" do | |
| 371 | + {user, private} = repository_fixture(%{visibility: "private"}) | |
| 372 | + root = "/pkg/hex/#{Storage.owner_handle(user)}/#{private.name}" | |
| 373 | + | |
| 374 | + conn = get(build_conn(), root <> "/names") | |
| 375 | + | |
| 376 | + assert conn.status == 401 | |
| 377 | + assert [_] = get_resp_header(conn, "www-authenticate") | |
| 378 | + end | |
| 379 | + end | |
| 380 | +end |
Parents: ed33862