Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions lib/loopctl_web/controllers/tenant_audit_key_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ defmodule LoopctlWeb.TenantAuditKeyController do
alias Loopctl.Tenants
alias Loopctl.WebAuthn

# Rotation requires user role — agents must not rotate keys
plug LoopctlWeb.Plugs.RequireRole, [role: :user] when action in [:rotate]
# Key management requires user role — agents must not rotate/bootstrap keys
plug LoopctlWeb.Plugs.RequireRole, [role: :user] when action in [:rotate, :bootstrap]

@doc """
GET /api/v1/tenants/:id/audit_public_key
Expand Down Expand Up @@ -160,6 +160,65 @@ defmodule LoopctlWeb.TenantAuditKeyController do
)
end

@doc """
POST /api/v1/tenants/:id/bootstrap-audit-key

Generates the initial ed25519 audit keypair for a legacy tenant that
predates the Chain of Custody v2 signup ceremony. Caller must own
the target tenant. Refuses if a key already exists.
"""
def bootstrap(conn, %{"id" => tenant_id}) do
caller_tenant_id =
case conn.assigns do
%{current_api_key: %{tenant_id: tid}} -> tid
_ -> nil
end

if caller_tenant_id != tenant_id do
conn
|> put_status(:forbidden)
|> json(%{error: %{message: "Forbidden", status: 403}})
else
do_bootstrap(conn, tenant_id)
end
end

defp do_bootstrap(conn, tenant_id) do
case Tenants.bootstrap_audit_key(tenant_id) do
{:ok, tenant} ->
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

# Encode ed25519 public key as SubjectPublicKeyInfo DER wrapped in PEM.
# OID for Ed25519: 1.3.101.112 → {0x06, 0x03, 0x2B, 0x65, 0x70}
# SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
Expand Down
4 changes: 1 addition & 3 deletions lib/loopctl_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ defmodule LoopctlWeb.Router do
get "/tenants/me", TenantController, :show
patch "/tenants/me", TenantController, :update
post "/tenants/:id/rotate-audit-key", TenantAuditKeyController, :rotate
post "/tenants/:id/bootstrap-audit-key", TenantAuditKeyController, :bootstrap

# US-26.2.1 — Dispatch lineage
resources "/dispatches", DispatchController, only: [:create, :show, :index]
Expand Down Expand Up @@ -362,8 +363,5 @@ 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
Loading