LemonCore.Secrets (lemon_core v0.1.0)

View Source

Encrypted secrets storage backed by LemonCore.Store.

Secret values are encrypted at rest and never returned by list/status APIs.

Master key

Values are encrypted with AES-256-GCM under a per-secret key derived from the master key (LemonCore.Secrets.Crypto). Where that master key comes from — system keychain, environment variable, key file, or a provider you supply — is configurable; see LemonCore.Secrets.KeyProvider.

config :lemon_core, LemonCore.Secrets,
  key_providers: [:keychain, :env, :file],
  key_file: "~/.lemon/secrets_master_key",
  env_var: "LEMON_SECRETS_MASTER_KEY"

Key rotation

Rotation is not supported yet: there is no re-encryption path, so replacing the master key makes every stored secret undecryptable. Tracked as item 1.5 in docs/platform-split.md.

The workaround is export/import — with the old key still in place, read the plaintext values out, swap the key, then write them back:

{:ok, entries} = LemonCore.Secrets.list()
exported = Map.new(entries, &{&1.name, elem(LemonCore.Secrets.get(&1.name), 1)})
# rotate the key (e.g. mix lemon.secrets.init --force), then:
Enum.each(exported, fn {name, value} -> LemonCore.Secrets.set(name, value) end)

Keep the exported values in memory (or a 0600 file you delete afterwards), never in shell history or logs.

Summary

Types

name()

@type name() :: String.t()

owner()

@type owner() :: String.t()

secret_metadata()

@type secret_metadata() :: %{
  owner: owner(),
  name: name(),
  provider: String.t(),
  expires_at: integer() | nil,
  usage_count: non_neg_integer(),
  last_used_at: integer() | nil,
  created_at: integer(),
  updated_at: integer(),
  version: String.t()
}

Functions

default_owner()

@spec default_owner() :: owner()

delete(name, opts \\ [])

@spec delete(
  name(),
  keyword()
) :: :ok | {:error, atom()}

exists?(name, opts \\ [])

@spec exists?(
  name(),
  keyword()
) :: boolean()

fetch_value(name, opts \\ [])

@spec fetch_value(
  name(),
  keyword()
) :: String.t() | nil

Convenience wrapper around resolve/2 that returns just the value or nil.

Drop-in replacement for System.get_env/1 — tries the encrypted store first, then falls back to the environment variable of the same name.

iex> LemonCore.Secrets.fetch_value("ANTHROPIC_API_KEY")
"sk-ant-..."

iex> LemonCore.Secrets.fetch_value("MISSING_KEY")
nil

get(name, opts \\ [])

@spec get(
  name(),
  keyword()
) :: {:ok, String.t()} | {:error, atom()}

import_from_env(name, opts \\ [])

@spec import_from_env(
  name(),
  keyword()
) :: {:ok, secret_metadata()} | {:error, atom()}

Import a secret from an environment variable into the encrypted store.

Options

  • :env_name - The environment variable name (defaults to secret name)
  • :delete_after - If true, deletes the env var after import (default: false)
  • Other options are passed to set/3

Examples

iex> System.put_env("OLD_API_KEY", "sk-...")
iex> LemonCore.Secrets.import_from_env("NEW_API_KEY", env_name: "OLD_API_KEY")
{:ok, %{name: "NEW_API_KEY", ...}}

list(opts \\ [])

@spec list(keyword()) :: {:ok, [secret_metadata()]}

persist(name, value, opts \\ [])

@spec persist(name(), String.t(), keyword()) ::
  {:ok, secret_metadata()} | {:error, atom()}

Persist a secret to the encrypted store.

This is an alias for set/3 that provides a consistent API for modules that need a persist/2 function.

Examples

iex> LemonCore.Secrets.persist("MY_API_KEY", "sk-...")
{:ok, %{name: "MY_API_KEY", ...}}

resolve(name, opts \\ [])

@spec resolve(
  name(),
  keyword()
) :: {:ok, String.t(), :store | :env} | {:error, atom()}

set(name, value, opts \\ [])

@spec set(name(), String.t(), keyword()) ::
  {:ok, secret_metadata()} | {:error, atom()}

status(opts \\ [])

@spec status(keyword()) :: map()

table()

@spec table() :: atom()