Implement the schema foundation for syncing products from POD providers like Printify. This includes encrypted credential storage, product/variant schemas, and an Oban worker for background sync. New modules: - Vault: AES-256-GCM encryption for API keys - Products context: CRUD and sync operations for products - Provider behaviour: abstraction for POD provider implementations - ProductSyncWorker: Oban job for async product sync Schemas: ProviderConnection, Product, ProductImage, ProductVariant Also reorganizes Printify client to lib/simpleshop_theme/clients/ and mockup generator to lib/simpleshop_theme/mockups/ for better structure. 134 tests added covering all new functionality. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
102 lines
2.6 KiB
Elixir
102 lines
2.6 KiB
Elixir
defmodule SimpleshopTheme.Vault do
|
|
@moduledoc """
|
|
Handles encryption and decryption of sensitive data.
|
|
|
|
Uses AES-256-GCM for authenticated encryption.
|
|
Keys are derived from the application's secret_key_base.
|
|
"""
|
|
|
|
@aad "SimpleshopTheme.Vault"
|
|
|
|
@doc """
|
|
Encrypts a string value.
|
|
|
|
Returns `{:ok, encrypted_binary}` or `{:error, reason}`.
|
|
The encrypted binary includes the IV and auth tag.
|
|
"""
|
|
@spec encrypt(String.t()) :: {:ok, binary()} | {:error, term()}
|
|
def encrypt(plaintext) when is_binary(plaintext) do
|
|
key = derive_key()
|
|
iv = :crypto.strong_rand_bytes(12)
|
|
|
|
{ciphertext, tag} =
|
|
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, plaintext, @aad, true)
|
|
|
|
# Format: iv (12 bytes) + tag (16 bytes) + ciphertext
|
|
{:ok, iv <> tag <> ciphertext}
|
|
rescue
|
|
e -> {:error, e}
|
|
end
|
|
|
|
def encrypt(nil), do: {:ok, nil}
|
|
|
|
@doc """
|
|
Decrypts an encrypted binary.
|
|
|
|
Returns `{:ok, plaintext}` or `{:error, reason}`.
|
|
"""
|
|
@spec decrypt(binary()) :: {:ok, String.t()} | {:error, term()}
|
|
def decrypt(<<iv::binary-12, tag::binary-16, ciphertext::binary>>) do
|
|
key = derive_key()
|
|
|
|
case :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, ciphertext, @aad, tag, false) do
|
|
plaintext when is_binary(plaintext) ->
|
|
{:ok, plaintext}
|
|
|
|
:error ->
|
|
{:error, :decryption_failed}
|
|
end
|
|
rescue
|
|
e -> {:error, e}
|
|
end
|
|
|
|
def decrypt(nil), do: {:ok, nil}
|
|
def decrypt(""), do: {:ok, ""}
|
|
|
|
def decrypt(_invalid) do
|
|
{:error, :invalid_ciphertext}
|
|
end
|
|
|
|
@doc """
|
|
Encrypts a string value, raising on error.
|
|
"""
|
|
@spec encrypt!(String.t()) :: binary()
|
|
def encrypt!(plaintext) do
|
|
case encrypt(plaintext) do
|
|
{:ok, ciphertext} -> ciphertext
|
|
{:error, reason} -> raise "Encryption failed: #{inspect(reason)}"
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Decrypts an encrypted binary, raising on error.
|
|
"""
|
|
@spec decrypt!(binary()) :: String.t()
|
|
def decrypt!(ciphertext) do
|
|
case decrypt(ciphertext) do
|
|
{:ok, plaintext} -> plaintext
|
|
{:error, reason} -> raise "Decryption failed: #{inspect(reason)}"
|
|
end
|
|
end
|
|
|
|
# Derives a 32-byte key from the secret_key_base
|
|
defp derive_key do
|
|
secret_key_base = get_secret_key_base()
|
|
|
|
:crypto.hash(:sha256, secret_key_base <> "vault_encryption_key")
|
|
end
|
|
|
|
defp get_secret_key_base do
|
|
case Application.get_env(:simpleshop_theme, SimpleshopThemeWeb.Endpoint)[:secret_key_base] do
|
|
nil ->
|
|
raise """
|
|
Secret key base is not configured.
|
|
Set it in config/runtime.exs or config/dev.exs.
|
|
"""
|
|
|
|
key when is_binary(key) ->
|
|
key
|
|
end
|
|
end
|
|
end
|