From 780d564fa8920019c7b530f0234b562011eb9173 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:46:50 -0700 Subject: [PATCH] feat: sign OTP build index files alongside builds.txt Adds optional SHA-512 signing of builds.txt via BOB_BUILDS_SIGN_KEY (path to a PEM private key). When set, generate_builds_txt/2 uploads a base64-encoded signature as a builds.txt.signed sibling with identical cache-control and surrogate-key metadata; Fastly purge keys cover both files atomically. Fixes #237 --- config/runtime.exs | 1 + lib/bob/artifacts.ex | 25 +++++++++++++-- lib/bob/store.ex | 26 ++++++++++++++++ test/bob/artifacts_test.exs | 62 +++++++++++++++++++++++++++++++++++++ test/bob/store_test.exs | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 3566302d..ce5264a5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,6 +7,7 @@ if config_env() == :prod do end config :bob, + builds_sign_key: System.get_env("BOB_BUILDS_SIGN_KEY"), github_user: System.fetch_env!("BOB_GITHUB_USER"), github_token: System.fetch_env!("BOB_GITHUB_TOKEN"), dockerhub_username: System.get_env("BOB_DOCKERHUB_USERNAME"), diff --git a/lib/bob/artifacts.ex b/lib/bob/artifacts.ex index 56c9c80d..61eea2d2 100644 --- a/lib/bob/artifacts.ex +++ b/lib/bob/artifacts.ex @@ -549,8 +549,9 @@ defmodule Bob.Artifacts do Repo.query!("SELECT pg_advisory_xact_lock($1, $2)", [@builds_txt_lock, lock_key(arch, os)]) path = "builds/otp/#{arch}/#{os}/builds.txt" + content = builds_txt(arch, os) - Bob.Store.put_file(path, builds_txt(arch, os), + Bob.Store.put_file(path, content, cache_control: "public,max-age=3600", meta: [ {"surrogate-key", surrogate_keys(arch, os)}, @@ -558,6 +559,24 @@ defmodule Bob.Artifacts do ] ) + sign_key = Application.get_env(:bob, :builds_sign_key) + + case Bob.Store.sign_content(sign_key, content) do + nil -> + :ok + + signature -> + signed_path = path <> ".signed" + + Bob.Store.put_file(signed_path, signature, + cache_control: "public,max-age=3600", + meta: [ + {"surrogate-key", surrogate_keys(arch, os)}, + {"surrogate-control", "public,max-age=604800"} + ] + ) + end + path end) @@ -573,11 +592,11 @@ defmodule Bob.Artifacts do end defp surrogate_keys(arch, os) do - "builds builds/otp builds/otp/#{arch} builds/otp/#{arch}/#{os} builds/otp/#{arch}/#{os}/txt" + "builds builds/otp builds/otp/#{arch} builds/otp/#{arch}/#{os} builds/otp/#{arch}/#{os}/txt builds/otp/#{arch}/#{os}/txt-signed" end defp purge_keys(arch, os, name) do - "builds/otp/#{arch}/#{os}/txt builds/otp/#{arch}/#{os}/#{name}" + "builds/otp/#{arch}/#{os}/txt builds/otp/#{arch}/#{os}/txt-signed builds/otp/#{arch}/#{os}/#{name}" end defp format_date(built_at) do diff --git a/lib/bob/store.ex b/lib/bob/store.ex index 999f24e3..0973c125 100644 --- a/lib/bob/store.ex +++ b/lib/bob/store.ex @@ -1,6 +1,32 @@ defmodule Bob.Store do @bucket "s3.hex.pm" + @doc """ + Signs `content` with the PEM private key at `pem_path` using + `openssl dgst -sha512 -sign` and returns the base64-encoded signature, or + `nil` when `pem_path` is `nil` (key not configured). + """ + def sign_content(nil, _content), do: nil + + def sign_content(pem_path, content) do + # Write content to a temp file so openssl can read from stdin cleanly for + # arbitrary binary/text payloads. + tmp = Path.join(System.tmp_dir!(), "bob_sign_#{System.unique_integer([:positive])}.tmp") + + try do + File.write!(tmp, content) + + case System.cmd("openssl", ["dgst", "-sha512", "-sign", pem_path, tmp], + stderr_to_stdout: false + ) do + {sig_binary, 0} -> Base.encode64(sig_binary) + {_output, code} -> raise "openssl dgst exited with #{code}" + end + after + File.rm(tmp) + end + end + # TODO: Use S3 object metadata def fetch_built_refs(build_path) do path = Path.join(build_path, "builds.txt") diff --git a/test/bob/artifacts_test.exs b/test/bob/artifacts_test.exs index 8e0e02de..781df7bf 100644 --- a/test/bob/artifacts_test.exs +++ b/test/bob/artifacts_test.exs @@ -146,6 +146,57 @@ defmodule Bob.ArtifactsTest do assert "builds/otp/amd64/ubuntu-24.04/builds.txt" = Artifacts.generate_builds_txt("amd64", "ubuntu-24.04") end + + test "does not upload builds.txt.signed when no sign key is configured" do + Bob.FakeHttpClient.reset() + + Bob.FakeHttpClient.stub( + :put, + "https://s3.amazonaws.com/s3.hex.pm/builds/otp/amd64/ubuntu-24.04/builds.txt", + 200, + "" + ) + + # No BOB_BUILDS_SIGN_KEY in app env -> sign_content returns nil. + Application.delete_env(:bob, :builds_sign_key) + Artifacts.upsert(attrs()) + + assert "builds/otp/amd64/ubuntu-24.04/builds.txt" = + Artifacts.generate_builds_txt("amd64", "ubuntu-24.04") + + # If a PUT for the .signed path had been attempted against the + # FakeHttpClient without a stub, ExAws would have raised on the 404 + # response (put_file uses request!/0). Reaching here means no PUT was + # attempted. + end + + test "uploads builds.txt.signed alongside builds.txt when a sign key is configured" do + Bob.FakeHttpClient.reset() + + Bob.FakeHttpClient.stub( + :put, + "https://s3.amazonaws.com/s3.hex.pm/builds/otp/amd64/ubuntu-24.04/builds.txt", + 200, + "" + ) + + Bob.FakeHttpClient.stub( + :put, + "https://s3.amazonaws.com/s3.hex.pm/builds/otp/amd64/ubuntu-24.04/builds.txt.signed", + 200, + "" + ) + + pem_path = generate_test_pem() + Application.put_env(:bob, :builds_sign_key, pem_path) + + Artifacts.upsert(attrs()) + + assert "builds/otp/amd64/ubuntu-24.04/builds.txt" = + Artifacts.generate_builds_txt("amd64", "ubuntu-24.04") + after + Application.delete_env(:bob, :builds_sign_key) + end end describe "built_otp_refs/2" do @@ -893,4 +944,15 @@ defmodule Bob.ArtifactsTest do built_at: ~U[2026-01-02 03:04:05.000000Z] } end + + # Lazily generates a shared RSA key for tests that exercise signing. + defp generate_test_pem() do + pem_path = Path.join(System.tmp_dir!(), "bob_artifacts_test_key.pem") + + unless File.exists?(pem_path) do + {_out, 0} = System.cmd("openssl", ["genrsa", "-out", pem_path, "2048"]) + end + + pem_path + end end diff --git a/test/bob/store_test.exs b/test/bob/store_test.exs index a2c496e1..7004174c 100644 --- a/test/bob/store_test.exs +++ b/test/bob/store_test.exs @@ -86,4 +86,61 @@ defmodule Bob.StoreTest do ) end end + + describe "sign_content/2" do + test "returns nil when no key path is configured" do + assert Store.sign_content(nil, "some content") == nil + end + + test "returns a base64 signature for a valid PEM key" do + pem_path = generate_test_key() + + signature = Store.sign_content(pem_path, "OTP-27.0 abc123 2026-01-01T00:00:00Z hash\n") + + assert is_binary(signature) + assert {:ok, _} = Base.decode64(signature) + assert byte_size(Base.decode64!(signature)) > 0 + end + + test "the signature verifies against the content using the matching public key" do + pem_path = generate_test_key() + pub_path = pem_path <> ".pub" + + # Export the public key once + {pub_pem, 0} = System.cmd("openssl", ["rsa", "-pubout", "-in", pem_path]) + File.write!(pub_path, pub_pem) + + content = "OTP-27.0 abc123 2026-01-01T00:00:00Z hash\n" + b64_sig = Store.sign_content(pem_path, content) + sig_bytes = Base.decode64!(b64_sig) + + # Write sig bytes to a temp file for verification + sig_path = pem_path <> ".sig" + File.write!(sig_path, sig_bytes) + + content_path = pem_path <> ".content" + File.write!(content_path, content) + + {_output, exit_code} = + System.cmd( + "openssl", + ["dgst", "-sha512", "-verify", pub_path, "-signature", sig_path, content_path] + ) + + assert exit_code == 0 + end + end + + # Generates a 2048-bit RSA key into a temp file and returns its path. + # The key is created once per process lifetime; the file persists across tests + # within the test run (harmless temp file, cleaned up by the OS). + defp generate_test_key() do + pem_path = Path.join(System.tmp_dir!(), "bob_test_key.pem") + + unless File.exists?(pem_path) do + {_out, 0} = System.cmd("openssl", ["genrsa", "-out", pem_path, "2048"]) + end + + pem_path + end end