cover/Elixir.WalletAuth.Jwks.KeySet.html

1 defmodule WalletAuth.Jwks.KeySet do
2 @moduledoc """
3 JWKS key set management and key rotation baseline.
4
5 Per ADR 0006:
6 - Keys identified by `kid` (key ID).
7 - Rotation: new key introduced before old key is retired.
8 - Overlap window ensures validation continuity during rotation.
9 - Key lifecycle events are audited.
10
11 Algorithm support:
12 - HS256 (HMAC-SHA256) — default for development/test.
13 - RS256 (RSA-SHA256) — production target; swap signer module via config.
14
15 In production, key material is sourced from the secret provider, never from
16 static config. Key IDs and versions are stored here; raw key bytes come from
17 WalletAuth.Secrets.SecretProvider.
18 """
19
20 alias WalletAuth.Secrets.SecretProvider
21
22 @type key_id :: String.t()
23 @type algorithm :: :HS256 | :RS256
24 @type key_entry :: %{
25 kid: key_id(),
26 algorithm: algorithm(),
27 status: :active | :retiring | :revoked,
28 activated_at: DateTime.t()
29 }
30
31 @doc """
32 Returns the current active signing key ID and its raw key material.
33 The key material is fetched from the secret provider on each call to
34 avoid caching stale values.
35
36 Returns `{kid, key_bytes}`.
37 """
38 @spec current_signing_key() :: {key_id(), binary()}
39 def current_signing_key do
40 18 kid = Application.get_env(:wallet_auth, :active_kid, "key_v1")
41 18 secret_name = "jwt_signing_key_#{kid}"
42 18 key_bytes = SecretProvider.get!(secret_name)
43 {kid, key_bytes}
44 end
45
46 @doc """
47 Returns all configured key IDs and their metadata (excludes raw key bytes).
48 Used for JWKS endpoint publication.
49 """
50 @spec all_key_metadata() :: [key_entry()]
51 def all_key_metadata do
52 9 Application.get_env(:wallet_auth, :jwks_keys, [
53 %{
54 kid: "key_v1",
55 algorithm: :HS256,
56 status: :active,
57 activated_at: ~U[2026-03-11 00:00:00Z]
58 }
59 ])
60 end
61
62 @doc """
63 Finds key material for validation by kid.
64 Supports overlap window: retiring keys can still validate tokens until revoked.
65
66 Returns `{:ok, key_bytes}` or `{:error, :kid_not_found | :key_revoked}`.
67 """
68 @spec find_validation_key(key_id()) ::
69 {:ok, binary()} | {:error, :kid_not_found | :key_revoked}
70 def find_validation_key(kid) do
71 9 keys = all_key_metadata()
72
73 9 case Enum.find(keys, &(&1.kid == kid)) do
74 1 nil ->
75 {:error, :kid_not_found}
76
77
:-(
%{status: :revoked} ->
78 {:error, :key_revoked}
79
80 %{status: status} when status in [:active, :retiring] ->
81 8 secret_name = "jwt_signing_key_#{kid}"
82
83 8 case SecretProvider.get(secret_name) do
84 8 {:ok, key_bytes} -> {:ok, key_bytes}
85
:-(
{:error, _} -> {:error, :kid_not_found}
86 end
87 end
88 end
89
90 @doc """
91 Rotates the active key by updating application config.
92 This is a runtime operation that should be followed by publishing the new JWKS.
93
94 In production, this triggers:
95 1. New kid becomes active.
96 2. Old kid enters :retiring status (validation still works).
97 3. After overlap window, old kid is revoked.
98
99 Returns `:ok`.
100 """
101 @spec rotate_to(key_id()) :: :ok
102 def rotate_to(new_kid) do
103
:-(
Application.put_env(:wallet_auth, :active_kid, new_kid)
104 :ok
105 end
106 end
Line Hits Source