Lockspire.Admin.Keys (lockspire v1.6.1)

Copy Markdown

Operator-facing query and command boundary for guided signing-key lifecycle work.

Summary

Functions

Reports how far stored signing keys are from the current key-encryption key.

Converts stored signing-key material to the current key-encryption key.

Types

encryption_status()

@type encryption_status() :: %{
  configured: boolean(),
  current_kek_id: String.t() | nil,
  configured_kek_ids: [String.t()],
  legacy_plaintext: :allow | :reject,
  total: non_neg_integer(),
  no_private_key: non_neg_integer(),
  legacy: non_neg_integer(),
  by_kek_id: %{optional(String.t()) => non_neg_integer()},
  unknown_kek: %{optional(String.t()) => non_neg_integer()},
  undecryptable: non_neg_integer(),
  stranded_retired: %{count: non_neg_integer(), kids: [String.t()]},
  pending: [
    %{
      kid: String.t(),
      state:
        :legacy
        | :unsealable_legacy
        | :other_kek
        | :unknown_kek
        | :undecryptable
    }
  ],
  ready?: boolean()
}

key_view()

@type key_view() :: %{
  key: Lockspire.Domain.SigningKey.t() | map(),
  published: boolean(),
  publishable: boolean(),
  next_actions: [lifecycle_action()]
}

lifecycle_action()

@type lifecycle_action() :: :publish | :activate | :retire

reencrypt_report()

@type reencrypt_report() :: %{
  dry_run: boolean(),
  current_kek_id: String.t(),
  scanned: non_neg_integer(),
  encrypted: non_neg_integer(),
  rewrapped: non_neg_integer(),
  already_current: non_neg_integer(),
  no_private_key: non_neg_integer(),
  conflicted: non_neg_integer(),
  by_source_kek: %{optional(String.t()) => non_neg_integer()},
  failed: [
    %{
      kid: String.t() | nil,
      reason: atom(),
      kek_id: String.t() | nil,
      status: Lockspire.Domain.SigningKey.status() | nil
    }
  ],
  changes: [
    %{kid: String.t(), action: :encrypted | :rewrapped, from: String.t()}
  ]
}

Functions

activate_key(key_id, attrs \\ %{})

@spec activate_key(integer(), map() | keyword()) ::
  {:ok, key_view()} | {:error, term()}

encryption_status()

@spec encryption_status() ::
  {:ok, encryption_status()}
  | {:error,
     :repo_not_started | :storage_unavailable | {:invalid_config, String.t()}}

Reports how far stored signing keys are from the current key-encryption key.

Every stored private key is opened with a freshly resolved key set and the plaintext is discarded, so a row sealed under the same id with different material counts as undecryptable. The result never contains stored bytes or key material.

ready? is true when a key-encryption key is configured, no legacy row remains, every row sits on the current id, and no unknown or undecryptable row remains. Retired keys that cannot be re-encrypted are listed under stranded_retired and never block readiness. This function reads only: it takes no locks, writes nothing and emits no telemetry.

generate_key(use \\ :sig)

@spec generate_key(Lockspire.Domain.SigningKey.use_type()) ::
  {:ok, key_view()} | {:error, :key_encryption_not_configured | term()}

get_key(key_id)

@spec get_key(integer()) :: {:ok, key_view() | nil} | {:error, term()}

list_keys(opts \\ [])

@spec list_keys(keyword()) :: {:ok, [key_view()]} | {:error, term()}

publish_key(key_id, attrs \\ %{})

@spec publish_key(integer(), map() | keyword()) ::
  {:ok, key_view()} | {:error, term()}

reencrypt(opts \\ [])

@spec reencrypt(keyword()) ::
  {:ok, reencrypt_report()}
  | {:error,
     :key_encryption_not_configured
     | :repo_not_started
     | :invalid_option
     | {:invalid_config, String.t()}}

Converts stored signing-key material to the current key-encryption key.

Legacy JSON or term-encoded rows are sealed into the encrypted envelope, and rows sealed under another configured key are re-wrapped under the current one. Rows already on the current key are opened to prove they decrypt and are left untouched. Every status and both key uses are covered, and no lifecycle column changes.

Each row is converted in its own transaction, so a row that cannot be converted is listed under failed in the report while the other rows stay committed. Running it again is safe: rows already on the current key are counted as already_current.

Options: :dry_run (default false) classifies every row and writes nothing, and :actor (a map with :type, :id and :display) is recorded on each audit entry. No other option is supported; :page_size and :after_lock are internal test seams and may change.

The repo must already be started. On a running release node, call this through bin/app rpc instead of a separate process.

retire_key(key_id, attrs \\ %{})

@spec retire_key(integer(), map() | keyword()) :: {:ok, key_view()} | {:error, term()}