Skip to content
Merged
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
3 changes: 3 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,15 @@ config :phoenix, :filter_parameters, [
"refresh_token",
"id_token",
"device_code",
"device_auth_id",
"user_code",
"code_verifier",
"client_secret",
"authorization",
"encrypted_credentials",
"account_id",
"chatgpt_account_id",
"chatgpt_user_id",
"organization_id",
"workspace_id"
]
Expand Down
31 changes: 22 additions & 9 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,15 @@ end
if config_env() == :prod do
current_key_version =
case System.get_env("KODO_CREDENTIAL_ENCRYPTION_CURRENT_KEY_VERSION") do
version when is_binary(version) and version != "" -> version
_invalid -> raise "KODO_CREDENTIAL_ENCRYPTION_CURRENT_KEY_VERSION must be set"
version when is_binary(version) ->
if Regex.match?(~r/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/, version) do
version
else
raise "KODO_CREDENTIAL_ENCRYPTION_CURRENT_KEY_VERSION is invalid"
end

_invalid ->
raise "KODO_CREDENTIAL_ENCRYPTION_CURRENT_KEY_VERSION must be set"
end

encoded_key_ring =
Expand All @@ -85,13 +92,19 @@ if config_env() == :prod do
{:ok, keys} <-
Enum.reduce_while(encoded_keys, {:ok, %{}}, fn
{version, encoded_key}, {:ok, keys}
when is_binary(version) and version != "" and is_binary(encoded_key) ->
case Base.decode64(encoded_key) do
{:ok, key} when byte_size(key) == 32 ->
{:cont, {:ok, Map.put(keys, version, key)}}

_invalid ->
{:halt, :error}
when is_binary(version) and is_binary(encoded_key) ->
with true <-
Regex.match?(~r/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/, version),
{:ok, key} <- Base.decode64(encoded_key) do
case key do
key when byte_size(key) == 32 ->
{:cont, {:ok, Map.put(keys, version, key)}}

_invalid ->
{:halt, :error}
end
else
_invalid -> {:halt, :error}
end

_invalid_entry, _keys ->
Expand Down
3 changes: 2 additions & 1 deletion docs/operations.org
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ Generate key material with a cryptographically secure generator, store it in
the deployment secret manager, and inject the same ring into every replica. Do
not derive it from ~SECRET_KEY_BASE~ or any provider credential. For example,
the shape is ~{"2026-09-v1":"<base64-encoded 32-byte key>"}~; never commit the
real value.
real value. Key versions must be 1–64 ASCII letters, digits, dots, underscores,
or hyphens, and must begin with a letter or digit.

Startup validates every configured key and refuses to proceed unless the
current version exists. After PostgreSQL starts, Kodo also verifies that every
Expand Down
30 changes: 25 additions & 5 deletions lib/kodo/integrations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ defmodule Kodo.Integrations do
end
end

def connect(%Scope{user: user}, provider, authentication_type, credentials, opts \\ []) do
def connect(scope, provider, authentication_type, credentials, opts \\ [])

def connect(%Scope{user: user}, provider, "api_key", credentials, opts) do
integration = %Integration{id: Ecto.UUID.generate(), user_id: user.id}

changeset =
Integration.create_changeset(integration, %{
provider: provider,
authentication_type: authentication_type
authentication_type: "api_key"
})

with true <- changeset.valid?,
Expand All @@ -56,12 +58,16 @@ defmodule Kodo.Integrations do
)
|> Integration.constraint_changeset()
|> Repo.insert()
|> normalize_insert_result()
else
false -> {:error, changeset}
{:error, _reason} = error -> error
end
end

def connect(%Scope{}, _provider, _authentication_type, _credentials, _opts),
do: {:error, :authentication_type_mismatch}

def replace_credentials(%Scope{} = scope, id, generation, credentials, opts \\ []) do
install_credentials(
scope,
Expand All @@ -70,7 +76,7 @@ defmodule Kodo.Integrations do
credentials,
opts,
Integration.connection_statuses(),
:any
"api_key"
)
end

Expand Down Expand Up @@ -191,8 +197,6 @@ defmodule Kodo.Integrations do
defp require_generation(%Integration{credential_generation: generation}, generation), do: :ok
defp require_generation(%Integration{}, _generation), do: {:error, :stale_credential_generation}

defp require_authentication_type(%Integration{}, :any), do: :ok

defp require_authentication_type(%Integration{authentication_type: type}, type), do: :ok

defp require_authentication_type(%Integration{}, _type),
Expand Down Expand Up @@ -228,5 +232,21 @@ defmodule Kodo.Integrations do
end
end

defp normalize_insert_result({:error, changeset} = error) do
cond do
constraint_error?(changeset, :foreign) -> {:error, :integration_owner_not_found}
constraint_error?(changeset, :unique) -> {:error, :integration_already_exists}
true -> error
end
end

defp normalize_insert_result(result), do: result

defp constraint_error?(changeset, type) do
Enum.any?(changeset.errors, fn {_field, {_message, metadata}} ->
metadata[:constraint] == type
end)
end

defp now, do: DateTime.utc_now()
end
19 changes: 17 additions & 2 deletions lib/kodo/integrations/credential_encryption.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ defmodule Kodo.Integrations.CredentialEncryption do
@nonce_bytes 12
@tag_bytes 16
@key_bytes 32
@key_version ~r/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/

@doc "Encrypts a credential payload using the configured current key."
def encrypt(%Integration{} = integration, payload) when is_map(payload) do
with {:ok, ring} <- key_ring(),
{:ok, associated_data} <- associated_data(integration, @format_version),
{:ok, plaintext} <- Jason.encode(payload) do
{:ok, plaintext} <- encode_payload(payload) do
nonce = :crypto.strong_rand_bytes(@nonce_bytes)
key = Map.fetch!(ring.keys, ring.current_key_version)

Expand All @@ -34,10 +35,13 @@ defmodule Kodo.Integrations.CredentialEncryption do
credential_format_version: @format_version
}}
else
{:error, :credential_payload_invalid} = error -> error
_error -> {:error, :credential_encryption_unavailable}
end
end

def encrypt(_integration, _payload), do: {:error, :credential_payload_invalid}

@doc "Decrypts and authenticates an integration's credential payload."
def decrypt(%Integration{credential_format_version: version}) when version != @format_version,
do: {:error, :credential_payload_version_unsupported}
Expand Down Expand Up @@ -101,7 +105,18 @@ defmodule Kodo.Integrations.CredentialEncryption do

defp valid_keys?(_keys), do: false

defp valid_version?(version), do: is_binary(version) and version != ""
defp valid_version?(version), do: is_binary(version) and Regex.match?(@key_version, version)

defp encode_payload(payload) do
try do
case Jason.encode(payload) do
{:ok, encoded} -> {:ok, encoded}
{:error, _reason} -> {:error, :credential_payload_invalid}
end
rescue
_exception -> {:error, :credential_payload_invalid}
end
end

defp associated_data(integration, format_version) do
values = [
Expand Down
1 change: 1 addition & 0 deletions lib/kodo/integrations/integration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ defmodule Kodo.Integrations.Integration do
@doc false
def constraint_changeset(changeset) do
changeset
|> foreign_key_constraint(:user_id)
|> check_constraint(:provider, name: :provider_integrations_provider_valid)
|> check_constraint(:authentication_type,
name: :provider_integrations_authentication_type_valid
Expand Down
23 changes: 23 additions & 0 deletions test/kodo/integrations/credential_encryption_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ defmodule Kodo.Integrations.CredentialEncryptionTest do
CredentialEncryption.encrypt(%Integration{}, %{"api_key" => "secret"})
end

test "rejects malformed payloads without raising or exposing them in diagnostics" do
sentinel = "plaintext-provider-secret"

malformed_payloads = [sentinel, %{"api_key" => ["value" | {sentinel}]}]

for payload <- malformed_payloads do
assert result = {:error, :credential_payload_invalid}
assert ^result = CredentialEncryption.encrypt(integration(), payload)
refute inspect(result) =~ sentinel
end
end

test "rejects key versions that cannot be persisted" do
invalid_versions = ["contains spaces", "é", String.duplicate("a", 65)]

for version <- invalid_versions do
put_config(version, %{version => :binary.copy(<<1>>, 32)})

assert {:error, :credential_encryption_config_invalid} =
CredentialEncryption.validate_config()
end
end

defp integration do
%Integration{
id: Ecto.UUID.generate(),
Expand Down
Loading
Loading