fix: fixed TD-01 and TD-25
This commit is contained in:
90
test/bds/ai/secret_backend_test.exs
Normal file
90
test/bds/ai/secret_backend_test.exs
Normal file
@@ -0,0 +1,90 @@
|
||||
defmodule BDS.AI.SecretBackendTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.AI.SecretBackend
|
||||
alias BDS.AI.SecretKey
|
||||
|
||||
# Key material shipped in the repository before TD-01. Kept here only to
|
||||
# prove that secrets stored by earlier releases remain readable.
|
||||
@legacy_repo_key binary_part(
|
||||
"bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001",
|
||||
0,
|
||||
32
|
||||
)
|
||||
|
||||
@aad "bds-ai-secret"
|
||||
|
||||
setup do
|
||||
original_key = Application.fetch_env(:bds, :ai_secret_key)
|
||||
original_config = Application.fetch_env(:bds, SecretKey)
|
||||
|
||||
on_exit(fn ->
|
||||
restore_env(:ai_secret_key, original_key)
|
||||
restore_env(SecretKey, original_config)
|
||||
SecretKey.reset_cache()
|
||||
end)
|
||||
|
||||
SecretKey.reset_cache()
|
||||
:ok
|
||||
end
|
||||
|
||||
defp restore_env(key, {:ok, value}), do: Application.put_env(:bds, key, value)
|
||||
defp restore_env(key, :error), do: Application.delete_env(:bds, key)
|
||||
|
||||
defp encrypt_with(key, value) do
|
||||
iv = :crypto.strong_rand_bytes(12)
|
||||
{ciphertext, tag} = :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true)
|
||||
Base.encode64(iv <> tag <> ciphertext)
|
||||
end
|
||||
|
||||
test "round-trips a secret with the current key" do
|
||||
assert {:ok, encrypted} = SecretBackend.encrypt("sk-live-12345")
|
||||
refute encrypted == "sk-live-12345"
|
||||
assert {:ok, "sk-live-12345"} = SecretBackend.decrypt(encrypted)
|
||||
end
|
||||
|
||||
test "rejects garbage ciphertext" do
|
||||
assert {:error, :invalid_ciphertext} = SecretBackend.decrypt("not-encrypted")
|
||||
assert {:error, :invalid_ciphertext} = SecretBackend.decrypt(Base.encode64("too-short"))
|
||||
end
|
||||
|
||||
test "still decrypts values encrypted with the legacy repo-baked key" do
|
||||
legacy_value = encrypt_with(@legacy_repo_key, "legacy-online-key")
|
||||
|
||||
assert {:ok, "legacy-online-key"} = SecretBackend.decrypt(legacy_value)
|
||||
end
|
||||
|
||||
test "still decrypts values encrypted with the legacy node-name key" do
|
||||
node_key = :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||
legacy_value = encrypt_with(node_key, "legacy-node-key-secret")
|
||||
|
||||
assert {:ok, "legacy-node-key-secret"} = SecretBackend.decrypt(legacy_value)
|
||||
end
|
||||
|
||||
test "decrypt_with_current_key does not accept legacy ciphertexts" do
|
||||
legacy_value = encrypt_with(@legacy_repo_key, "legacy-secret")
|
||||
|
||||
assert {:error, :invalid_ciphertext} = SecretBackend.decrypt_with_current_key(legacy_value)
|
||||
|
||||
assert {:ok, current_value} = SecretBackend.encrypt("current-secret")
|
||||
assert {:ok, "current-secret"} = SecretBackend.decrypt_with_current_key(current_value)
|
||||
end
|
||||
|
||||
test "fails loudly when no secret key can be obtained" do
|
||||
Application.delete_env(:bds, :ai_secret_key)
|
||||
|
||||
blocked_dir = Path.join(System.tmp_dir!(), "bds-blocked-#{System.unique_integer([:positive])}")
|
||||
File.write!(blocked_dir, "occupied")
|
||||
on_exit(fn -> File.rm(blocked_dir) end)
|
||||
|
||||
Application.put_env(:bds, SecretKey,
|
||||
strategy: :file,
|
||||
key_file_path: Path.join(blocked_dir, "ai_secret.key")
|
||||
)
|
||||
|
||||
assert {:error, :secret_key_unavailable} = SecretBackend.encrypt("anything")
|
||||
|
||||
valid_looking = encrypt_with(:crypto.strong_rand_bytes(32), "anything")
|
||||
assert {:error, :secret_key_unavailable} = SecretBackend.decrypt(valid_looking)
|
||||
end
|
||||
end
|
||||
173
test/bds/ai/secret_key_test.exs
Normal file
173
test/bds/ai/secret_key_test.exs
Normal file
@@ -0,0 +1,173 @@
|
||||
defmodule BDS.AI.SecretKeyTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Bitwise
|
||||
|
||||
alias BDS.AI.SecretKey
|
||||
|
||||
setup do
|
||||
original_key = Application.fetch_env(:bds, :ai_secret_key)
|
||||
original_config = Application.fetch_env(:bds, SecretKey)
|
||||
|
||||
on_exit(fn ->
|
||||
restore_env(:ai_secret_key, original_key)
|
||||
restore_env(SecretKey, original_config)
|
||||
SecretKey.reset_cache()
|
||||
end)
|
||||
|
||||
SecretKey.reset_cache()
|
||||
:ok
|
||||
end
|
||||
|
||||
defp restore_env(key, {:ok, value}), do: Application.put_env(:bds, key, value)
|
||||
defp restore_env(key, :error), do: Application.delete_env(:bds, key)
|
||||
|
||||
defp tmp_key_path do
|
||||
Path.join([
|
||||
System.tmp_dir!(),
|
||||
"bds-secret-key-test-#{System.unique_integer([:positive])}",
|
||||
"ai_secret.key"
|
||||
])
|
||||
end
|
||||
|
||||
describe "configured key override" do
|
||||
test "uses the first 32 bytes of an explicitly configured key" do
|
||||
Application.put_env(:bds, :ai_secret_key, String.duplicate("k", 40))
|
||||
|
||||
assert {:ok, key} = SecretKey.fetch()
|
||||
assert key == String.duplicate("k", 32)
|
||||
end
|
||||
|
||||
test "rejects a configured key that is too short instead of falling back" do
|
||||
Application.put_env(:bds, :ai_secret_key, "too-short")
|
||||
|
||||
assert {:error, {:invalid_configured_key, _detail}} = SecretKey.fetch()
|
||||
end
|
||||
end
|
||||
|
||||
describe "file strategy" do
|
||||
setup do
|
||||
Application.delete_env(:bds, :ai_secret_key)
|
||||
path = tmp_key_path()
|
||||
Application.put_env(:bds, SecretKey, strategy: :file, key_file_path: path)
|
||||
on_exit(fn -> File.rm_rf(Path.dirname(path)) end)
|
||||
{:ok, path: path}
|
||||
end
|
||||
|
||||
test "generates a random 32-byte key file with 0600 permissions on first use", %{path: path} do
|
||||
assert {:ok, key} = SecretKey.fetch()
|
||||
assert byte_size(key) == 32
|
||||
|
||||
assert File.exists?(path)
|
||||
assert (File.stat!(path).mode &&& 0o777) == 0o600
|
||||
assert {:ok, ^key} = path |> File.read!() |> String.trim() |> Base.decode64()
|
||||
end
|
||||
|
||||
test "returns the same key across cache resets by re-reading the file" do
|
||||
assert {:ok, key} = SecretKey.fetch()
|
||||
SecretKey.reset_cache()
|
||||
assert {:ok, ^key} = SecretKey.fetch()
|
||||
end
|
||||
|
||||
test "reads an existing key file", %{path: path} do
|
||||
key = :crypto.strong_rand_bytes(32)
|
||||
File.mkdir_p!(Path.dirname(path))
|
||||
File.write!(path, Base.encode64(key) <> "\n")
|
||||
|
||||
assert {:ok, ^key} = SecretKey.fetch()
|
||||
end
|
||||
|
||||
test "errors on a corrupt key file instead of overwriting it", %{path: path} do
|
||||
File.mkdir_p!(Path.dirname(path))
|
||||
File.write!(path, "not-valid-base64!!")
|
||||
|
||||
assert {:error, {:key_file_corrupt, ^path}} = SecretKey.fetch()
|
||||
assert File.read!(path) == "not-valid-base64!!"
|
||||
end
|
||||
|
||||
test "fails loudly when the key file location is not writable", %{path: path} do
|
||||
blocking_file = Path.dirname(path)
|
||||
File.mkdir_p!(Path.dirname(blocking_file))
|
||||
File.write!(blocking_file, "occupied")
|
||||
|
||||
assert {:error, _reason} = SecretKey.fetch()
|
||||
end
|
||||
end
|
||||
|
||||
describe "keychain strategy" do
|
||||
setup do
|
||||
Application.delete_env(:bds, :ai_secret_key)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "returns the stored key when the keychain item exists" do
|
||||
key = :crypto.strong_rand_bytes(32)
|
||||
|
||||
runner = fn "security", ["find-generic-password" | _rest], _opts ->
|
||||
{Base.encode64(key) <> "\n", 0}
|
||||
end
|
||||
|
||||
Application.put_env(:bds, SecretKey, strategy: :keychain, command_runner: runner)
|
||||
|
||||
assert {:ok, ^key} = SecretKey.fetch()
|
||||
end
|
||||
|
||||
test "creates and stores a new key when the keychain item is missing" do
|
||||
test_pid = self()
|
||||
|
||||
runner = fn "security", [verb | rest], _opts ->
|
||||
case verb do
|
||||
"find-generic-password" ->
|
||||
{"security: SecKeychainSearchCopyNext: The specified item could not be found.", 44}
|
||||
|
||||
"add-generic-password" ->
|
||||
send(test_pid, {:keychain_add, rest})
|
||||
{"", 0}
|
||||
end
|
||||
end
|
||||
|
||||
Application.put_env(:bds, SecretKey, strategy: :keychain, command_runner: runner)
|
||||
|
||||
assert {:ok, key} = SecretKey.fetch()
|
||||
assert byte_size(key) == 32
|
||||
|
||||
assert_received {:keychain_add, add_args}
|
||||
assert Base.encode64(key) in add_args
|
||||
end
|
||||
|
||||
test "falls back to the key file when the keychain is unavailable" do
|
||||
path = tmp_key_path()
|
||||
on_exit(fn -> File.rm_rf(Path.dirname(path)) end)
|
||||
|
||||
runner = fn "security", _args, _opts -> {"security: unknown error", 1} end
|
||||
|
||||
Application.put_env(:bds, SecretKey,
|
||||
strategy: :keychain,
|
||||
command_runner: runner,
|
||||
key_file_path: path
|
||||
)
|
||||
|
||||
assert {:ok, key} = SecretKey.fetch()
|
||||
assert byte_size(key) == 32
|
||||
assert File.exists?(path)
|
||||
end
|
||||
|
||||
test "caches the resolved key so the keychain is not queried per call" do
|
||||
test_pid = self()
|
||||
key = :crypto.strong_rand_bytes(32)
|
||||
|
||||
runner = fn "security", ["find-generic-password" | _rest], _opts ->
|
||||
send(test_pid, :keychain_find)
|
||||
{Base.encode64(key), 0}
|
||||
end
|
||||
|
||||
Application.put_env(:bds, SecretKey, strategy: :keychain, command_runner: runner)
|
||||
|
||||
assert {:ok, ^key} = SecretKey.fetch()
|
||||
assert {:ok, ^key} = SecretKey.fetch()
|
||||
|
||||
assert_received :keychain_find
|
||||
refute_received :keychain_find
|
||||
end
|
||||
end
|
||||
end
|
||||
112
test/bds/ai/secret_migration_test.exs
Normal file
112
test/bds/ai/secret_migration_test.exs
Normal file
@@ -0,0 +1,112 @@
|
||||
defmodule BDS.AI.SecretMigrationTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
alias BDS.AI.SecretBackend
|
||||
alias BDS.AI.SecretMigration
|
||||
alias BDS.Persistence
|
||||
alias BDS.Repo
|
||||
alias BDS.Settings.Setting
|
||||
|
||||
# Key material shipped in the repository before TD-01; used to seed rows the
|
||||
# way earlier releases stored them.
|
||||
@legacy_repo_key binary_part(
|
||||
"bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001",
|
||||
0,
|
||||
32
|
||||
)
|
||||
|
||||
@aad "bds-ai-secret"
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp encrypt_with(key, value) do
|
||||
iv = :crypto.strong_rand_bytes(12)
|
||||
{ciphertext, tag} = :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true)
|
||||
Base.encode64(iv <> tag <> ciphertext)
|
||||
end
|
||||
|
||||
defp insert_setting(key, value) do
|
||||
%Setting{}
|
||||
|> Setting.changeset(%{key: key, value: value, updated_at: Persistence.now_ms()})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
test "re-encrypts secrets stored with the legacy repo key" do
|
||||
legacy_value = encrypt_with(@legacy_repo_key, "sk-online-123")
|
||||
insert_setting("__encrypted_ai.online.api_key", legacy_value)
|
||||
|
||||
assert {:ok, %{migrated: 1, failed: 0}} = SecretMigration.migrate_legacy_secrets()
|
||||
|
||||
%Setting{value: new_value} = Repo.get(Setting, "__encrypted_ai.online.api_key")
|
||||
refute new_value == legacy_value
|
||||
assert {:ok, "sk-online-123"} = SecretBackend.decrypt_with_current_key(new_value)
|
||||
end
|
||||
|
||||
test "re-encrypts secrets stored with the legacy node-name key" do
|
||||
node_key = :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||
legacy_value = encrypt_with(node_key, "sk-airplane-456")
|
||||
insert_setting("__encrypted_ai.airplane.api_key", legacy_value)
|
||||
|
||||
assert {:ok, %{migrated: 1, failed: 0}} = SecretMigration.migrate_legacy_secrets()
|
||||
|
||||
%Setting{value: new_value} = Repo.get(Setting, "__encrypted_ai.airplane.api_key")
|
||||
assert {:ok, "sk-airplane-456"} = SecretBackend.decrypt_with_current_key(new_value)
|
||||
end
|
||||
|
||||
test "leaves rows already encrypted with the current key untouched" do
|
||||
{:ok, current_value} = SecretBackend.encrypt("sk-current-789")
|
||||
insert_setting("__encrypted_ai.online.api_key", current_value)
|
||||
|
||||
assert {:ok, %{migrated: 0, failed: 0}} = SecretMigration.migrate_legacy_secrets()
|
||||
|
||||
assert %Setting{value: ^current_value} = Repo.get(Setting, "__encrypted_ai.online.api_key")
|
||||
end
|
||||
|
||||
test "is idempotent" do
|
||||
legacy_value = encrypt_with(@legacy_repo_key, "sk-online-123")
|
||||
insert_setting("__encrypted_ai.online.api_key", legacy_value)
|
||||
|
||||
assert {:ok, %{migrated: 1, failed: 0}} = SecretMigration.migrate_legacy_secrets()
|
||||
%Setting{value: migrated_value} = Repo.get(Setting, "__encrypted_ai.online.api_key")
|
||||
|
||||
assert {:ok, %{migrated: 0, failed: 0}} = SecretMigration.migrate_legacy_secrets()
|
||||
assert %Setting{value: ^migrated_value} = Repo.get(Setting, "__encrypted_ai.online.api_key")
|
||||
end
|
||||
|
||||
test "leaves undecryptable rows in place and reports them" do
|
||||
unknown_value = encrypt_with(:crypto.strong_rand_bytes(32), "lost-secret")
|
||||
insert_setting("__encrypted_ai.online.api_key", unknown_value)
|
||||
|
||||
log =
|
||||
capture_log(fn ->
|
||||
assert {:ok, %{migrated: 0, failed: 1}} = SecretMigration.migrate_legacy_secrets()
|
||||
end)
|
||||
|
||||
assert log =~ "__encrypted_ai.online.api_key"
|
||||
assert %Setting{value: ^unknown_value} = Repo.get(Setting, "__encrypted_ai.online.api_key")
|
||||
end
|
||||
|
||||
test "ignores settings that are not encrypted secrets" do
|
||||
insert_setting("ai.online.url", "https://api.example.test/v1")
|
||||
|
||||
assert {:ok, %{migrated: 0, failed: 0}} = SecretMigration.migrate_legacy_secrets()
|
||||
|
||||
assert %Setting{value: "https://api.example.test/v1"} = Repo.get(Setting, "ai.online.url")
|
||||
end
|
||||
|
||||
test "runs as part of repo bootstrap" do
|
||||
legacy_value = encrypt_with(@legacy_repo_key, "sk-bootstrap-123")
|
||||
insert_setting("__encrypted_ai.online.api_key", legacy_value)
|
||||
|
||||
assert :ok = BDS.RepoBootstrap.ensure_ready(migrate?: false)
|
||||
|
||||
%Setting{value: new_value} = Repo.get(Setting, "__encrypted_ai.online.api_key")
|
||||
refute new_value == legacy_value
|
||||
assert {:ok, "sk-bootstrap-123"} = SecretBackend.decrypt_with_current_key(new_value)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user