Skip to content

Secrets and Keychain Contract

This document defines the tested contract for Lemon secret resolution across keychain, encrypted store, explicitly enabled external sources, and environment fallback paths.

Flow Matrix

LayerWrite PathRead/Resolve PathFallbacksPrimary Files
macOS Keychain master keyLemonCore.Secrets.MasterKey.init/1 -> Keychain.put_master_key/2LemonCore.Secrets.MasterKey.resolve/1 -> Keychain.get_master_key/1On :missing / :keychain_unavailable / command failures, tries env master key, then local fileapps/lemon_core/lib/lemon_core/secrets/keychain.ex, apps/lemon_core/lib/lemon_core/secrets/master_key.ex
Master key env fallbackManual set of LEMON_SECRETS_MASTER_KEY (external)MasterKey.resolve/1 via resolve_from_env/1On missing env, tries ~/.lemon/secrets_master_key; malformed env still fails as :invalid_master_keyapps/lemon_core/lib/lemon_core/secrets/master_key.ex
Local master key file fallback~/.lemon/secrets_master_keyMasterKey.resolve/1 via local file fallbackReturns :missing_master_key or :invalid_master_key when no valid source existsapps/lemon_core/lib/lemon_core/secrets/master_key.ex
Encrypted secret storeLemonCore.Secrets.set/3 (AES-256-GCM at rest)LemonCore.Secrets.get/2, resolve/2, exists?/2resolve/2 uses store, enabled external sources, then same-name env when env_fallback: trueapps/lemon_core/lib/lemon_core/secrets.ex
External secret sourcesRead-only; no external result is persistedLemonCore.Secrets.External invokes 1Password, Bitwarden, or argv-only command adapters under shared supervisionOrdered by priority/id; a source miss continues, but any enabled-source failure stops before env fallbackapps/lemon_core/lib/lemon_core/secrets/external.ex, apps/lemon_core/lib/lemon_core/secrets/source_runner.ex
External bootstrap credentialsExisting Lemon encrypted secret or same-name env onlyLemonCore.Secrets.resolve_local/2 from a source adapterNever invokes an external source, preventing recursionapps/lemon_core/lib/lemon_core/secrets.ex, apps/lemon_core/lib/lemon_core/secrets/source/
Coding Agent provider secret refsConfigured api_key_secret names in provider configCodingAgent.Session.resolve_secret_api_key/1 -> LemonCore.Secrets.resolve/2Store first, enabled external sources second, env fallback lastapps/coding_agent/lib/coding_agent/session.ex

Keychain Error Semantics (Current Contract)

LemonCore.Secrets.Keychain maps command outcomes to stable errors:

  • Exit code 44 -> {:error, :missing}
  • Non-zero exit codes -> {:error, {:command_failed, code, stderr_or_output}}
  • Timeout (Task.yield expiry) -> {:error, :timeout}
  • Non-macOS / missing security executable -> {:error, :unavailable}
  • Empty retrieved value -> treated as missing ({:error, :missing})

Master Key Resolution Precedence

LemonCore.Secrets.MasterKey.resolve/1 order:

  1. Keychain (:keychain source)
  2. LEMON_SECRETS_MASTER_KEY (:env source)
  3. ~/.lemon/secrets_master_key (:file source)
  4. Error (:missing_master_key, :invalid_master_key, or {:keychain_failed, reason})

The chain itself is configurable — providers implement LemonCore.Secrets.KeyProvider:

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

Additional nuance:

  • The keychain provider is macOS-only and skips itself on other platforms, so a Linux host with no key configured reports :missing_master_key rather than a keychain failure.
  • If keychain returns malformed key material, env fallback is attempted first; only then returns :invalid_master_key.
  • Key material must be base64-encoded 32-byte data. Raw passphrase-like strings are rejected with :weak_master_key (there is no password stretching); allow_legacy_raw_keys: true restores the old behaviour for setups that already encrypted secrets under such a value.
  • Key rotation (re-encryption under a new master key) is not implemented — see the "Key rotation" section of the LemonCore.Secrets moduledoc and item 1.5 in docs/platform-split.md.
  • status/1 suppresses expected keychain absence (:missing, :keychain_unavailable) from keychain_error while still surfacing hard failures.
  • The local source launcher bin/lemon also normalizes LEMON_SECRETS_MASTER_KEY from ~/.lemon/secrets_master_key on non-macOS systems so stale desktop/session env does not override the working local key by accident.

Operator Notes

  • mix lemon.secrets.init is the preferred bootstrap path everywhere: it stores the generated key in the keychain on macOS and writes the key file (0600) on other platforms. It refuses to overwrite an existing key file unless --force is passed.
  • For local non-macOS development, keep ~/.lemon/secrets_master_key as the canonical master key file. bin/lemon will export that value into LEMON_SECRETS_MASTER_KEY before boot when the file exists.
  • secrets.list and secrets.status return metadata only (never plaintext secret values).
  • lemon secrets sources status|test returns only source readiness, provenance, counts, byte counts, duration, and stable error kinds. Combined stdout/stderr and resolved values are never included.
  • External commands are direct argv execution with exact configuration, minimal child environments, 100..30000 ms timeouts, 1..1048576 byte output bounds, and an optional bounded process-local cache that defaults off.
  • If keychain prompts are denied (User interaction is not allowed), Lemon can still operate via env fallback when configured.

Validation References

  • apps/lemon_core/test/lemon_core/secrets/keychain_test.exs
  • apps/lemon_core/test/lemon_core/secrets/master_key_test.exs
  • apps/lemon_core/test/lemon_core/secrets_test.exs
  • apps/lemon_core/test/lemon_core/secrets/external_test.exs
  • apps/lemon_core/test/lemon_core/secrets/source_contract_test.exs
  • apps/lemon_core/test/lemon_core/config/secrets_test.exs
  • apps/lemon_cli/test/lemon_cli/secret_sources_command_test.exs

Released under the MIT License.