DpExchange.Gemini.Credentials (DpExchangeGemini v0.1.37)

Copy Markdown View Source

A redacting wrapper for the credentials the host passes in — internal.

This exists for one reason: a raw secret in a supervisor's stored child spec ends up in the log in cleartext. A supervisor holds the {module, :start_link, [opts]} MFA it was handed, and OTP writes that argument list through inspect/1 into the Start Call: line of the report it logs whenever the child terminates. A plain %{api_key: ..., api_secret: ...} map therefore prints its values, in full, on any child crash.

dp-exchange-core issue #29: a consumer found live API keys in cleartext in ordinary application logs and nearly pasted them into a GitHub issue while reporting a different bug. Application logs are exactly the artifact most likely to be shipped to an aggregator, attached to a bug report, or quoted in a ticket, so this defeats credential hygiene upstream of it — a consumer can hold the key encrypted at rest and still have it written out in the clear by a crash.

Redacting the value rather than suppressing the report is deliberate. A :sensitive process flag would also hide the secret, and would hide the stack trace with it — the one that made the unrelated bug diagnosable in the first place. This keeps the report and removes only the secret.

Why Inspect, and not a scrub at each log site

OTP formats those args with inspect/1, so one redacting Inspect implementation covers every path at once: supervisor reports, crash reports, :sys.get_state/1 dumps, and anything a consumer inspects itself. There is no list of log call sites to keep current, which is the kind of list that silently stops being complete.

A struct is a map, so nothing downstream changes: Auth.headers/5 pattern-matches only the keys it needs and keeps working unchanged.

Both schemes, one struct

Gemini takes two shapes — an :api_key pair and an :oauth access token (see DpExchange.Gemini.Auth). All three fields live here rather than in two structs, because Kernel.struct/2 ignores keys the struct does not declare, so one struct accepts either shape and redacts whichever arrived. Splitting them would mean choosing which struct to build from an untagged map — an inference this family does not make about credentials.

Summary

Functions

Wraps a raw credentials map.

Wraps the :credentials entry of an options keyword list, IN PLACE and only when that key is actually present.

Types

t()

@type t() :: %DpExchange.Gemini.Credentials{
  access_token: String.t() | nil,
  api_key: String.t() | nil,
  api_secret: String.t() | nil
}

Functions

wrap(credentials)

@spec wrap(map()) :: t()

Wraps a raw credentials map.

Any map is struct-ified with Kernel.struct/2, which ignores keys the struct does not declare rather than raising — matching how Auth.headers/5 already reads this map, by pattern-matching only the keys it needs.

wrap_opt(opts)

@spec wrap_opt(keyword()) :: keyword()

Wraps the :credentials entry of an options keyword list, IN PLACE and only when that key is actually present.

Applied in child_spec/1, which is the only place early enough: wrapping inside start_link/1 or init/1 does nothing for the supervisor's report, because by then the raw list has already been captured by the supervisor above.