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
1 change: 1 addition & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
25 changes: 22 additions & 3 deletions lib/bob/artifacts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -549,15 +549,34 @@ 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)},
{"surrogate-control", "public,max-age=604800"}
]
)

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)

Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lib/bob/store.ex
Original file line number Diff line number Diff line change
@@ -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],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use :public_key.sign/3 instead?

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")
Expand Down
62 changes: 62 additions & 0 deletions test/bob/artifacts_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
57 changes: 57 additions & 0 deletions test/bob/store_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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