Files
bDS2/lib/bds/ai/secret_key.ex

234 lines
6.8 KiB
Elixir

defmodule BDS.AI.SecretKey do
@moduledoc """
Resolves the 32-byte machine-local master key that encrypts AI provider
secrets at rest (the `SecureKeyStore` entity in `specs/ai.allium`).
Resolution order:
1. `config :bds, :ai_secret_key` — explicit override. Used by the test
suite for determinism; must be at least 32 bytes (the first 32 are
used). An invalid value is an error, never a silent fallback.
2. A previously resolved key cached in `:persistent_term`.
3. The OS keyring: on macOS the login Keychain via the `security` CLI; on
other platforms — or when the Keychain is unavailable — a random key
file under the private app dir, written with `0600` permissions.
A fresh random key is generated and stored on first use. There is no
deterministic fallback: when no key can be obtained or persisted, `fetch/0`
returns `{:error, reason}` and secret encryption/decryption fails loudly
instead of degrading to obfuscation.
Config (`config :bds, BDS.AI.SecretKey`):
* `:strategy` — `:auto` (default; Keychain on macOS, key file elsewhere),
`:keychain`, or `:file`
* `:key_file_path` — overrides the key file location
* `:command_runner` — 3-arity replacement for `System.cmd/3` (tests)
"""
require Logger
@key_bytes 32
@cache_key {__MODULE__, :key}
@keychain_service "bDS2"
@keychain_account "ai-secret-key"
@keychain_not_found_status 44
@key_file_name "ai_secret.key"
@spec fetch() :: {:ok, binary()} | {:error, term()}
def fetch do
case configured_key() do
{:ok, key} -> {:ok, key}
{:error, _detail} = error -> error
:unset -> cached_or_resolve()
end
end
@doc "Clears the cached key so the next fetch re-resolves it (test helper)."
@spec reset_cache() :: :ok
def reset_cache do
:persistent_term.erase(@cache_key)
:ok
end
defp configured_key do
case Application.get_env(:bds, :ai_secret_key) do
nil ->
:unset
key when is_binary(key) and byte_size(key) >= @key_bytes ->
{:ok, binary_part(key, 0, @key_bytes)}
other ->
{:error, {:invalid_configured_key, "expected a binary of at least #{@key_bytes} bytes, got: #{inspect(other)}"}}
end
end
defp cached_or_resolve do
case :persistent_term.get(@cache_key, nil) do
key when is_binary(key) ->
{:ok, key}
nil ->
with {:ok, key} <- resolve() do
:persistent_term.put(@cache_key, key)
{:ok, key}
end
end
end
defp resolve do
case strategy() do
:keychain -> resolve_keychain()
:file -> resolve_file()
end
end
defp strategy do
case config(:strategy, :auto) do
:auto ->
if match?({:unix, :darwin}, :os.type()), do: :keychain, else: :file
explicit when explicit in [:keychain, :file] ->
explicit
end
end
# ─── macOS Keychain ─────────────────────────────────────────
defp resolve_keychain do
case keychain_find() do
{:ok, key} ->
{:ok, key}
:not_found ->
keychain_create()
{:error, reason} ->
Logger.warning(
"AI secret key: macOS Keychain unavailable (#{inspect(reason)}); falling back to the key file"
)
resolve_file()
end
end
defp keychain_find do
case run_security([
"find-generic-password",
"-s",
@keychain_service,
"-a",
@keychain_account,
"-w"
]) do
{output, 0} ->
case output |> String.trim() |> Base.decode64() do
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
_other -> {:error, :corrupt_keychain_item}
end
{_output, @keychain_not_found_status} ->
:not_found
{output, status} ->
{:error, {:security_failed, status, String.trim(output)}}
end
end
# The generated key passes through `security`'s argv, which is briefly
# visible to other local processes of the same user. Accepted trade-off for
# a single-user desktop app; `security` offers no non-interactive way to
# take the password on stdin in one shot.
defp keychain_create do
key = :crypto.strong_rand_bytes(@key_bytes)
case run_security([
"add-generic-password",
"-U",
"-s",
@keychain_service,
"-a",
@keychain_account,
"-w",
Base.encode64(key)
]) do
{_output, 0} ->
{:ok, key}
{output, status} ->
Logger.warning(
"AI secret key: could not store the key in the Keychain " <>
"(status #{status}: #{String.trim(output)}); falling back to the key file"
)
resolve_file()
end
end
defp run_security(args) do
runner = config(:command_runner, &default_runner/3)
runner.("security", args, stderr_to_stdout: true)
end
defp default_runner(command, args, opts) do
System.cmd(command, args, opts)
rescue
error in ErlangError -> {"#{command} unavailable: #{inspect(error.original)}", 127}
end
# ─── Key file ───────────────────────────────────────────────
defp resolve_file do
path = key_file_path()
case File.read(path) do
{:ok, contents} -> decode_key_file(contents, path)
{:error, :enoent} -> create_key_file(path)
{:error, reason} -> {:error, {:key_file_unreadable, path, reason}}
end
end
defp decode_key_file(contents, path) do
case contents |> String.trim() |> Base.decode64() do
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
_other -> {:error, {:key_file_corrupt, path}}
end
end
defp create_key_file(path) do
key = :crypto.strong_rand_bytes(@key_bytes)
temp_path = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))
with :ok <- File.mkdir_p(Path.dirname(path)),
:ok <- File.write(temp_path, Base.encode64(key) <> "\n"),
:ok <- File.chmod(temp_path, 0o600),
:ok <- File.rename(temp_path, path) do
{:ok, key}
else
{:error, reason} ->
_ = File.rm(temp_path)
{:error, {:key_file_write_failed, path, reason}}
end
end
defp key_file_path do
config(:key_file_path, nil) || Path.join(private_app_dir(), @key_file_name)
end
# Same private app dir as BDS.Projects.private_app_dir/0 — on macOS
# ~/Library/Application Support/BDS2. Duplicated to keep this module free of
# project/DB dependencies.
defp private_app_dir do
case :filename.basedir(:user_config, "BDS2") do
path when is_list(path) -> List.to_string(path)
path -> path
end
|> Path.expand()
end
defp config(key, default) do
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
end
end