Skip to content

Commit 4f030af

Browse files
committed
feat(jar): add JWE decryption support for request objects
- Adds :enc key use type to Domain.SigningKey and Storage.Ecto.SigningKeyRecord - Adds generate_key/1 to Lockspire.Admin.Keys and UI for :enc keys - Implements Jar.decrypt/2 using JOSE.JWE to decrypt nested JWTs - Updates RequestObject to decrypt incoming JARs before verification - Isolates active key fetches in Repository by use type (:sig vs :enc) - Updates JWKS controller to publish both :sig and :enc keys
1 parent e657782 commit 4f030af

16 files changed

Lines changed: 318 additions & 19 deletions

File tree

lib/lockspire/admin.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ defmodule Lockspire.Admin do
3030
defdelegate revoke_token_family(token_id, attrs \\ %{}), to: Tokens
3131
defdelegate list_keys(opts \\ []), to: Keys
3232
defdelegate get_key(key_id), to: Keys
33+
defdelegate generate_key(use \\ :sig), to: Keys
3334
defdelegate publish_key(key_id, attrs \\ %{}), to: Keys
3435
defdelegate activate_key(key_id, attrs \\ %{}), to: Keys
3536
defdelegate retire_key(key_id, attrs \\ %{}), to: Keys

lib/lockspire/admin/keys.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,44 @@ defmodule Lockspire.Admin.Keys do
3131
end
3232
end
3333

34+
@spec generate_key(SigningKey.use_type()) :: {:ok, key_view()} | {:error, term()}
35+
def generate_key(use \\ :sig) do
36+
jwk = JOSE.JWK.generate_key({:rsa, 2048})
37+
{_, public_jwk_map} = JOSE.JWK.to_map(JOSE.JWK.to_public(jwk))
38+
{_, private_jwk_map} = JOSE.JWK.to_map(jwk)
39+
40+
kid = Base.encode16(:crypto.strong_rand_bytes(8))
41+
42+
public_jwk_map =
43+
public_jwk_map
44+
|> Map.put("use", Atom.to_string(use))
45+
|> Map.put("kid", kid)
46+
47+
private_jwk_map = Map.put(private_jwk_map, "kid", kid)
48+
49+
key = %SigningKey{
50+
kid: kid,
51+
kty: :RSA,
52+
alg: "RS256",
53+
use: use,
54+
public_jwk: public_jwk_map,
55+
private_jwk_encrypted: :erlang.term_to_binary(private_jwk_map),
56+
status: :upcoming,
57+
inserted_at: DateTime.utc_now(),
58+
updated_at: DateTime.utc_now()
59+
}
60+
61+
with {:ok, published_key} <- transact_with_audit(
62+
fn -> Repository.publish_key(key) end,
63+
fn %SigningKey{} = k ->
64+
key_audit_event(:key_generated, k, actor_from_attrs(%{}), %{use: use})
65+
end
66+
) do
67+
emit(:key_generated, published_key, actor_from_attrs(%{}))
68+
{:ok, to_view(published_key)}
69+
end
70+
end
71+
3472
@spec publish_key(integer(), map() | keyword()) :: {:ok, key_view()} | {:error, term()}
3573
def publish_key(key_id, attrs \\ %{})
3674

lib/lockspire/domain/signing_key.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Lockspire.Domain.SigningKey do
44
"""
55

66
@type key_type :: :RSA | :EC | :OKP
7-
@type use_type :: :sig
7+
@type use_type :: :sig | :enc
88
@type status :: :upcoming | :active | :retiring | :retired
99

1010
@type t :: %__MODULE__{

lib/lockspire/protocol/jar.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ defmodule Lockspire.Protocol.Jar do
3333
# "none" is never permitted — it would allow unsigned requests to bypass auth.
3434
@allowed_algorithms ~w(RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512 EdDSA)
3535

36+
@doc """
37+
Decrypts a nested JWE request object to return the inner JWS string.
38+
If the input is not a JWE (e.g. it is a 3-part JWS), it returns `{:ok, jwt}` immediately.
39+
"""
40+
@spec decrypt(String.t(), [Lockspire.Domain.SigningKey.t()]) :: {:ok, String.t()} | {:error, :decryption_failed}
41+
def decrypt(jwt, decryption_keys) when is_binary(jwt) do
42+
if length(String.split(jwt, ".")) == 5 do
43+
Enum.reduce_while(decryption_keys, {:error, :decryption_failed}, fn key, _acc ->
44+
try do
45+
jwk_map = :erlang.binary_to_term(key.private_jwk_encrypted)
46+
jwk = JOSE.JWK.from_map(jwk_map)
47+
case JOSE.JWK.block_decrypt(jwt, jwk) do
48+
{plain_text, %JOSE.JWE{}} -> {:halt, {:ok, plain_text}}
49+
_ -> {:cont, {:error, :decryption_failed}}
50+
end
51+
rescue
52+
_ -> {:cont, {:error, :decryption_failed}}
53+
catch
54+
_, _ -> {:cont, {:error, :decryption_failed}}
55+
end
56+
end)
57+
else
58+
{:ok, jwt}
59+
end
60+
end
61+
3662
@doc """
3763
Decodes a JWT string without signature verification.
3864
"""

lib/lockspire/protocol/request_object.ex

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ defmodule Lockspire.Protocol.RequestObject do
2727
alias Lockspire.Domain.Client
2828
alias Lockspire.Protocol.AuthorizationRequest.Error
2929
alias Lockspire.Protocol.Jar
30+
alias Lockspire.Storage.Ecto.Repository
3031

3132
@type result ::
3233
{:ok, map()}
@@ -40,14 +41,30 @@ defmodule Lockspire.Protocol.RequestObject do
4041
with :ok <- reject_request_uri_collision(params),
4142
:ok <- reject_outer_param_conflicts(params),
4243
{:ok, jwt} <- fetch_request(params),
44+
{:ok, jws_string} <- decrypt_request(jwt),
4345
:ok <- require_client_jwks(client),
44-
{:ok, %Jar{} = jar} <- decode_and_verify(jwt, client),
46+
{:ok, %Jar{} = jar} <- decode_and_verify(jws_string, client),
4547
:ok <- validate(jar, client, opts),
4648
{:ok, projected} <- project_to_params(jar, client) do
4749
{:ok, projected}
4850
end
4951
end
5052

53+
defp decrypt_request(jwt) do
54+
with {:ok, dec_keys} <- Repository.list_decryption_keys(),
55+
{:ok, jws_string} <- Jar.decrypt(jwt, dec_keys) do
56+
{:ok, jws_string}
57+
else
58+
_ ->
59+
{:browser_error,
60+
browser_error(
61+
:invalid_request_object,
62+
"Request object decryption failed",
63+
:invalid_request_object_decryption
64+
)}
65+
end
66+
end
67+
5168
defp reject_request_uri_collision(%{"request_uri" => request_uri}) do
5269
if present?(request_uri) do
5370
{:browser_error,

lib/lockspire/storage/ecto/repository.ex

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -893,10 +893,25 @@ defmodule Lockspire.Storage.Ecto.Repository do
893893
error -> {:error, error}
894894
end
895895

896+
@impl KeyStore
897+
def list_decryption_keys do
898+
SigningKeyRecord
899+
|> where([key], key.use == :enc)
900+
|> where([key], key.status in [:active, :retiring])
901+
|> order_by([key], asc: key.inserted_at)
902+
|> repo().all()
903+
|> then(fn records ->
904+
{:ok, Enum.map(records, &SigningKeyRecord.to_domain/1)}
905+
end)
906+
rescue
907+
error -> {:error, error}
908+
end
909+
896910
@impl KeyStore
897911
def fetch_active_signing_key do
898912
SigningKeyRecord
899913
|> where([key], key.status == :active)
914+
|> where([key], key.use == :sig)
900915
|> order_by([key], asc: key.inserted_at)
901916
|> limit(1)
902917
|> repo().one()
@@ -1515,7 +1530,7 @@ defmodule Lockspire.Storage.Ecto.Repository do
15151530
do: repo().rollback(:not_published)
15161531

15171532
defp activate_signing_key_record(%SigningKeyRecord{} = selected_record, activated_at) do
1518-
case fetch_active_signing_key_records() do
1533+
case fetch_active_signing_key_records(selected_record.use) do
15191534
[] ->
15201535
%{
15211536
activated_key: activate_selected_signing_key(selected_record, activated_at),
@@ -1533,9 +1548,10 @@ defmodule Lockspire.Storage.Ecto.Repository do
15331548
end
15341549
end
15351550

1536-
defp fetch_active_signing_key_records do
1551+
defp fetch_active_signing_key_records(use) do
15371552
SigningKeyRecord
15381553
|> where([key], key.status == :active)
1554+
|> where([key], key.use == ^use)
15391555
|> lock("FOR UPDATE")
15401556
|> repo().all()
15411557
end

lib/lockspire/storage/ecto/signing_key_record.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule Lockspire.Storage.Ecto.SigningKeyRecord do
1313
field(:kid, :string)
1414
field(:kty, Ecto.Enum, values: [:RSA, :EC, :OKP])
1515
field(:alg, :string)
16-
field(:use, Ecto.Enum, values: [:sig])
16+
field(:use, Ecto.Enum, values: [:sig, :enc])
1717
field(:public_jwk, :map)
1818
field(:private_jwk_encrypted, :binary)
1919
field(:status, Ecto.Enum, values: [:upcoming, :active, :retiring, :retired])

lib/lockspire/storage/key_store.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule Lockspire.Storage.KeyStore do
1111
@callback list_active_keys() :: {:ok, [SigningKey.t()]} | {:error, store_error()}
1212
@callback list_signing_keys(keyword()) :: {:ok, [SigningKey.t()]} | {:error, store_error()}
1313
@callback list_publishable_keys() :: {:ok, [SigningKey.t()]} | {:error, store_error()}
14+
@callback list_decryption_keys() :: {:ok, [SigningKey.t()]} | {:error, store_error()}
1415
@callback fetch_active_signing_key() :: {:ok, SigningKey.t() | nil} | {:error, store_error()}
1516
@callback fetch_signing_key_by_id(integer()) ::
1617
{:ok, SigningKey.t() | nil} | {:error, store_error()}

lib/lockspire/web/live/admin/keys_live/index.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ defmodule Lockspire.Web.Live.Admin.KeysLive.Index do
2929
)}
3030
end
3131

32+
@impl true
33+
def handle_event("generate", %{"use" => use}, socket) do
34+
use_atom = String.to_existing_atom(use)
35+
36+
case Admin.generate_key(use_atom) do
37+
{:ok, _key_view} ->
38+
keys = load_keys()
39+
{:noreply, assign(socket, keys: keys, total_keys: length(keys))}
40+
41+
{:error, _reason} ->
42+
{:noreply, socket}
43+
end
44+
end
45+
3246
@impl true
3347
def render(assigns) do
3448
~H"""
@@ -37,6 +51,11 @@ defmodule Lockspire.Web.Live.Admin.KeysLive.Index do
3751
title="Signing key lifecycle"
3852
subtitle="Inspect upcoming, active, retiring, and retired keys without exposing raw status editing."
3953
>
54+
<div class="lockspire-admin-actions" style="margin-bottom: 1rem; display: flex; gap: 1rem;">
55+
<button phx-click="generate" phx-value-use="sig" class="button">Generate Signing Key</button>
56+
<button phx-click="generate" phx-value-use="enc" class="button button-secondary">Generate Encryption Key</button>
57+
</div>
58+
4059
<p>Total keys in durable storage: {@total_keys}</p>
4160
4261
<%= if @keys == [] do %>
@@ -50,6 +69,7 @@ defmodule Lockspire.Web.Live.Admin.KeysLive.Index do
5069
<li>
5170
<a href={key_show_path(entry.key.id)}>{entry.key.kid}</a>
5271
<span>{entry.key.alg} / {entry.key.kty}</span>
72+
<span>Use: {entry.key.use}</span>
5373
<AdminComponents.status_badge status={entry.key.status} />
5474
<span>JWKS {if entry.publishable, do: "visible", else: "hidden"}</span>
5575
<span>Next action {format_actions(entry.next_actions)}</span>

priv/repo/migrations/20260429110000_add_logout_propagation_fields_to_lockspire_clients.exs

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)