From 268767b95afeb82de4431f15cd8d9af810904af6 Mon Sep 17 00:00:00 2001 From: Mark Kreyman Date: Sun, 12 Apr 2026 22:10:50 -0600 Subject: [PATCH] Add bootstrap-audit-key endpoint for legacy tenants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy tenants created before Chain of Custody v2 have no ed25519 audit keypair, leaving trust layers L1/L2/L5/L6 inert. This adds: - Tenants.bootstrap_audit_key/1 — generates initial keypair, stores private key in Fly secrets, sets public key on tenant record - POST /api/v1/admin/tenants/:id/bootstrap-audit-key (superadmin) - Refuses to overwrite existing keys (use rotate instead) - Writes audit chain entry documenting the bootstrap Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/loopctl/tenants.ex | 58 +++++++++++++++++++ .../controllers/admin_tenant_controller.ex | 55 ++++++++++++++++++ lib/loopctl_web/router.ex | 3 + 3 files changed, 116 insertions(+) diff --git a/lib/loopctl/tenants.ex b/lib/loopctl/tenants.ex index f65bc63..2968643 100644 --- a/lib/loopctl/tenants.ex +++ b/lib/loopctl/tenants.ex @@ -229,6 +229,64 @@ defmodule Loopctl.Tenants do {pub, priv} end + @doc """ + Generate an initial audit signing keypair for a legacy tenant that + doesn't have one yet. Refuses to overwrite an existing key — use + `rotate_audit_key/2` for that. + + Returns `{:ok, updated_tenant}` or `{:error, reason}`. + """ + @spec bootstrap_audit_key(Ecto.UUID.t()) :: {:ok, Tenant.t()} | {:error, term()} + def bootstrap_audit_key(tenant_id) do + case get_tenant(tenant_id) do + {:error, _} = err -> + err + + {:ok, %{audit_signing_public_key: existing}} when not is_nil(existing) -> + {:error, :key_already_exists} + + {:ok, tenant} -> + do_bootstrap_audit_key(tenant) + end + end + + defp do_bootstrap_audit_key(tenant) do + {pub, priv} = generate_ed25519_keypair() + secret_name = Secrets.audit_key_secret_name(tenant.slug) + + multi = + Multi.new() + |> Multi.run(:store_secret, fn _repo, _changes -> + case Secrets.set(secret_name, priv) do + :ok -> {:ok, :stored} + {:error, reason} -> {:error, {:audit_key_storage_failed, reason}} + end + end) + |> Multi.update(:set_public_key, fn _ -> + Ecto.Changeset.change(tenant, audit_signing_public_key: pub) + end) + |> Audit.log_in_multi(:audit_bootstrap, fn %{set_public_key: updated} -> + %{ + tenant_id: updated.id, + entity_type: "tenant", + entity_id: updated.id, + action: "audit_key_bootstrapped", + actor_type: "superadmin", + actor_id: nil, + actor_label: "superadmin:bootstrap", + new_state: %{ + "audit_signing_public_key" => Base.encode64(pub), + "reason" => "Legacy tenant — no key existed prior to Chain of Custody v2" + } + } + end) + + case AdminRepo.transaction(multi) do + {:ok, %{set_public_key: tenant}} -> {:ok, tenant} + {:error, _step, reason, _changes} -> {:error, reason} + end + end + @doc """ Rotate the tenant's audit signing key. Requires a WebAuthn assertion to authorize. Stores the old public key in `tenant_audit_key_history`, diff --git a/lib/loopctl_web/controllers/admin_tenant_controller.ex b/lib/loopctl_web/controllers/admin_tenant_controller.ex index 365049e..dbd53dc 100644 --- a/lib/loopctl_web/controllers/admin_tenant_controller.ex +++ b/lib/loopctl_web/controllers/admin_tenant_controller.ex @@ -311,6 +311,61 @@ defmodule LoopctlWeb.AdminTenantController do defp ceil_div(numerator, denominator), do: ceil(numerator / denominator) + @doc """ + POST /api/v1/admin/tenants/:id/bootstrap-audit-key + + Generates the initial ed25519 audit keypair for a legacy tenant that + predates the Chain of Custody v2 signup ceremony. Refuses to run if + the tenant already has a key — use rotate-audit-key for that. + """ + def bootstrap_audit_key(conn, %{"id" => id}) do + case Tenants.bootstrap_audit_key(id) do + {:ok, tenant} -> + api_key = conn.assigns.current_api_key + + Loopctl.AuditChain.append(tenant.id, %{ + action: "audit_key_bootstrapped", + actor_lineage: [], + entity_type: "tenant", + entity_id: tenant.id, + payload: %{"bootstrapped_by" => api_key.id} + }) + + json(conn, %{ + data: %{ + tenant_id: tenant.id, + audit_signing_public_key: Base.encode64(tenant.audit_signing_public_key), + message: "Audit keypair generated. Trust layers L1, L2, L5, L6 are now active." + } + }) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: %{message: "Not found", status: 404}}) + + {:error, :key_already_exists} -> + conn + |> put_status(:conflict) + |> json(%{ + error: %{ + message: "Tenant already has an audit key. Use rotate-audit-key instead.", + status: 409 + } + }) + + {:error, {:audit_key_storage_failed, _reason}} -> + conn + |> put_status(:internal_server_error) + |> json(%{ + error: %{message: "Failed to store the audit key. Please retry.", status: 500} + }) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: %{message: "Key bootstrap failed", status: 500}}) + end + end + @doc "POST /api/v1/admin/tenants/:id/clear-halt — clears custody halt (break-glass, requires WebAuthn)" def clear_halt(conn, %{"id" => id} = params) do # G7: Break-glass requires WebAuthn assertion diff --git a/lib/loopctl_web/router.ex b/lib/loopctl_web/router.ex index 1fbe9a3..4498b0f 100644 --- a/lib/loopctl_web/router.ex +++ b/lib/loopctl_web/router.ex @@ -362,5 +362,8 @@ defmodule LoopctlWeb.Router do # US-26.5.2 — Custody halt management post "/tenants/:id/clear-halt", AdminTenantController, :clear_halt + + # Legacy tenant audit key bootstrap + post "/tenants/:id/bootstrap-audit-key", AdminTenantController, :bootstrap_audit_key end end