simpleshop_theme/lib/simpleshop_theme/vault.ex
Jamey Greenwood c5c06d9979 feat: add Products context with provider integration (Phase 1)
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>
2026-01-29 20:32:20 +00:00

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