Skip to content

Commit b8537aa

Browse files
authored
feat(config): add generation for JWT keys if missing (#8655)
1 parent cb4a5de commit b8537aa

File tree

7 files changed

+326
-43
lines changed

7 files changed

+326
-43
lines changed

.env

Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -85,44 +85,9 @@ DJANGO_CACHE_MAX_AGE=3600
8585
DJANGO_STALE_WHILE_REVALIDATE=60
8686
DJANGO_MANAGE_DB_PARTITIONS=True
8787
# openssl genrsa -out private.pem 2048
88-
DJANGO_TOKEN_SIGNING_KEY="-----BEGIN PRIVATE KEY-----
89-
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDs4e+kt7SnUJek
90-
6V5r9zMGzXCoU5qnChfPiqu+BgANyawz+MyVZPs6RCRfeo6tlCknPQtOziyXYM2I
91-
7X+qckmuzsjqp8+u+o1mw3VvUuJew5k2SQLPYwsiTzuFNVJEOgRo3hywGiGwS2iv
92-
/5nh2QAl7fq2qLqZEXQa5+/xJlQggS1CYxOJgggvLyra50QZlBvPve/AxKJ/EV/Q
93-
irWTZU5lLNI8sH2iZR05vQeBsxZ0dCnGMT+vGl+cGkqrvzQzKsYbDmabMcfTYhYi
94-
78fpv6A4uharJFHayypYBjE39PwhMyyeycrNXlpm1jpq+03HgmDuDMHydk1tNwuT
95-
nEC7m7iNAgMBAAECggEAA2m48nJcJbn9SVi8bclMwKkWmbJErOnyEGEy2sTK3Of+
96-
NWx9BB0FmqAPNxn0ss8K7cANKOhDD7ZLF9E2MO4/HgfoMKtUzHRbM7MWvtEepldi
97-
nnvcUMEgULD8Dk4HnqiIVjt3BdmGiTv46OpBnRWrkSBV56pUL+7msZmMZTjUZvh2
98-
ZWv0+I3gtDIjo2Zo/FiwDV7CfwRjJarRpYUj/0YyuSA4FuOUYl41WAX1I301FKMH
99-
xo3jiAYi1s7IneJ16OtPpOA34Wg5F6ebm/UO0uNe+iD4kCXKaZmxYQPh5tfB0Qa3
100-
qj1T7GNpFNyvtG7VVdauhkb8iu8X/wl6PCwbg0RCKQKBgQD9HfpnpH0lDlHMRw9K
101-
X7Vby/1fSYy1BQtlXFEIPTN/btJ/asGxLmAVwJ2HAPXWlrfSjVAH7CtVmzN7v8oj
102-
HeIHfeSgoWEu1syvnv2AMaYSo03UjFFlfc/GUxF7DUScRIhcJUPCP8jkAROz9nFv
103-
DByNjUL17Q9r43DmDiRsy0IFqQKBgQDvlJ9Uhl+Sp7gRgKYwa/IG0+I4AduAM+Gz
104-
Dxbm52QrMGMTjaJFLmLHBUZ/ot+pge7tZZGws8YR8ufpyMJbMqPjxhIvRRa/p1Tf
105-
E3TQPW93FMsHUvxAgY3MV5MzXFPhlNAKb+akP/RcXUhetGAuZKLubtDCWa55ZQuL
106-
wj2OS+niRQKBgE7K8zUqNi6/22S8xhy/2GPgB1qPObbsABUofK0U6CAGLo6te+gc
107-
6Jo84IyzFtQbDNQFW2Fr+j1m18rw9AqkdcUhQndiZS9AfG07D+zFB86LeWHt4DS4
108-
ymIRX8Kvaak/iDcu/n3Mf0vCrhB6aetImObTj4GgrwlFvtJOmrYnO8EpAoGAIXXP
109-
Xt25gWD9OyyNiVu6HKwA/zN7NYeJcRmdaDhO7B1A6R0x2Zml4AfjlbXoqOLlvLAf
110-
zd79vcoAC82nH1eOPiSOq51plPDI0LMF8IN0CtyTkn1Lj7LIXA6rF1RAvtOqzppc
111-
SvpHpZK9pcRpXnFdtBE0BMDDtl6fYzCIqlP94UUCgYEAnhXbAQMF7LQifEm34Dx8
112-
BizRMOKcqJGPvbO2+Iyt50O5X6onU2ITzSV1QHtOvAazu+B1aG9pEuBFDQ+ASxEu
113-
L9ruJElkOkb/o45TSF6KCsHd55ReTZ8AqnRjf5R+lyzPqTZCXXb8KTcRvWT4zQa3
114-
VxyT2PnaSqEcexWUy4+UXoQ=
115-
-----END PRIVATE KEY-----"
88+
DJANGO_TOKEN_SIGNING_KEY=""
11689
# openssl rsa -in private.pem -pubout -out public.pem
117-
DJANGO_TOKEN_VERIFYING_KEY="-----BEGIN PUBLIC KEY-----
118-
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7OHvpLe0p1CXpOlea/cz
119-
Bs1wqFOapwoXz4qrvgYADcmsM/jMlWT7OkQkX3qOrZQpJz0LTs4sl2DNiO1/qnJJ
120-
rs7I6qfPrvqNZsN1b1LiXsOZNkkCz2MLIk87hTVSRDoEaN4csBohsEtor/+Z4dkA
121-
Je36tqi6mRF0Gufv8SZUIIEtQmMTiYIILy8q2udEGZQbz73vwMSifxFf0Iq1k2VO
122-
ZSzSPLB9omUdOb0HgbMWdHQpxjE/rxpfnBpKq780MyrGGw5mmzHH02IWIu/H6b+g
123-
OLoWqyRR2ssqWAYxN/T8ITMsnsnKzV5aZtY6avtNx4Jg7gzB8nZNbTcLk5xAu5u4
124-
jQIDAQAB
125-
-----END PUBLIC KEY-----"
90+
DJANGO_TOKEN_VERIFYING_KEY=""
12691
# openssl rand -base64 32
12792
DJANGO_SECRETS_ENCRYPTION_KEY="oE/ltOhp/n1TdbHjVmzcjDPLcLA41CVI/4Rk+UB5ESc="
12893
DJANGO_BROKER_VISIBILITY_TIMEOUT=86400

api/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
All notable changes to the **Prowler API** are documented in this file.
44

5-
## [1.14.0] (Prowler 5.13.0)
5+
## [1.14.0] (Prowler UNRELEASED)
6+
7+
### Added
8+
- Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655)
69

710
### Changed
811
- Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281)

api/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Valkey exposes a Redis 7.2 compliant API. Any service that exposes the Redis API
2020

2121
Under the root path of the project, you can find a file called `.env.example`. This file shows all the environment variables that the project uses. You *must* create a new file called `.env` and set the values for the variables.
2222

23+
If you don’t set `DJANGO_TOKEN_SIGNING_KEY` or `DJANGO_TOKEN_VERIFYING_KEY`, the API will generate them at `~/.config/prowler-api/` with `0600` and `0644` permissions; back up these files to persist identity across redeploys.
24+
2325
## Local deployment
2426
Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the Poetry interpreter, not before. Otherwise, variables will not be loaded properly.
2527

api/src/backend/api/apps.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,28 @@
1+
import logging
2+
import os
3+
4+
from pathlib import Path
5+
import sys
6+
17
from django.apps import AppConfig
8+
from django.conf import settings
9+
10+
from config.custom_logging import BackendLogger
11+
from config.env import env
12+
13+
logger = logging.getLogger(BackendLogger.API)
14+
15+
SIGNING_KEY_ENV = "DJANGO_TOKEN_SIGNING_KEY"
16+
VERIFYING_KEY_ENV = "DJANGO_TOKEN_VERIFYING_KEY"
17+
18+
PRIVATE_KEY_FILE = "jwt_private.pem"
19+
PUBLIC_KEY_FILE = "jwt_public.pem"
20+
21+
KEYS_DIRECTORY = (
22+
Path.home() / ".config" / "prowler-api"
23+
) # `/home/prowler/.config/prowler-api` inside the container
24+
25+
_keys_initialized = False # Flag to prevent multiple executions within the same process
226

327

428
class ApiConfig(AppConfig):
@@ -9,4 +33,138 @@ def ready(self):
933
from api import signals # noqa: F401
1034
from api.compliance import load_prowler_compliance
1135

36+
# Generate required cryptographic keys if not present, but only if:
37+
# `"manage.py" not in sys.argv`: If an external server (e.g., Gunicorn) is running the app
38+
# `os.environ.get("RUN_MAIN")`: If it's not a Django command or using `runserver`,
39+
# only the main process will do it
40+
if "manage.py" not in sys.argv or os.environ.get("RUN_MAIN"):
41+
self._ensure_crypto_keys()
42+
1243
load_prowler_compliance()
44+
45+
def _ensure_crypto_keys(self):
46+
"""
47+
Orchestrator method that ensures all required cryptographic keys are present.
48+
This method coordinates the generation of:
49+
- RSA key pairs for JWT token signing and verification
50+
Note: During development, Django spawns multiple processes (migrations, fixtures, etc.)
51+
which will each generate their own keys. This is expected behavior and each process
52+
will have consistent keys for its lifetime. In production, set the keys as environment
53+
variables to avoid regeneration.
54+
"""
55+
global _keys_initialized
56+
57+
# Skip key generation if running tests
58+
if hasattr(settings, "TESTING") and settings.TESTING:
59+
return
60+
61+
# Skip if already initialized in this process
62+
if _keys_initialized:
63+
return
64+
65+
# Check if both JWT keys are set; if not, generate them
66+
signing_key = env.str(SIGNING_KEY_ENV, default="").strip()
67+
verifying_key = env.str(VERIFYING_KEY_ENV, default="").strip()
68+
69+
if not signing_key or not verifying_key:
70+
logger.info(
71+
f"Generating JWT RSA key pair. In production, set '{SIGNING_KEY_ENV}' and '{VERIFYING_KEY_ENV}' "
72+
"environment variables."
73+
)
74+
self._ensure_jwt_keys()
75+
76+
# Mark as initialized to prevent future executions in this process
77+
_keys_initialized = True
78+
79+
def _read_key_file(self, file_name):
80+
"""
81+
Utility method to read the contents of a file.
82+
"""
83+
file_path = KEYS_DIRECTORY / file_name
84+
return file_path.read_text().strip() if file_path.is_file() else None
85+
86+
def _write_key_file(self, file_name, content, private=True):
87+
"""
88+
Utility method to write content to a file.
89+
"""
90+
try:
91+
file_path = KEYS_DIRECTORY / file_name
92+
file_path.parent.mkdir(parents=True, exist_ok=True)
93+
file_path.write_text(content)
94+
file_path.chmod(0o600 if private else 0o644)
95+
96+
except Exception as e:
97+
logger.error(
98+
f"Error writing key file '{file_name}': {e}. "
99+
f"Please set '{SIGNING_KEY_ENV}' and '{VERIFYING_KEY_ENV}' manually."
100+
)
101+
raise e
102+
103+
def _ensure_jwt_keys(self):
104+
"""
105+
Generate RSA key pairs for JWT token signing and verification
106+
if they are not already set in environment variables.
107+
"""
108+
# Read existing keys from files if they exist
109+
signing_key = self._read_key_file(PRIVATE_KEY_FILE)
110+
verifying_key = self._read_key_file(PUBLIC_KEY_FILE)
111+
112+
if not signing_key or not verifying_key:
113+
# Generate and store the RSA key pair
114+
signing_key, verifying_key = self._generate_jwt_keys()
115+
self._write_key_file(PRIVATE_KEY_FILE, signing_key, private=True)
116+
self._write_key_file(PUBLIC_KEY_FILE, verifying_key, private=False)
117+
logger.info("JWT keys generated and stored successfully")
118+
119+
else:
120+
logger.info("JWT keys already generated")
121+
122+
# Set environment variables and Django settings
123+
os.environ[SIGNING_KEY_ENV] = signing_key
124+
settings.SIMPLE_JWT["SIGNING_KEY"] = signing_key
125+
126+
os.environ[VERIFYING_KEY_ENV] = verifying_key
127+
settings.SIMPLE_JWT["VERIFYING_KEY"] = verifying_key
128+
129+
def _generate_jwt_keys(self):
130+
"""
131+
Generate and set RSA key pairs for JWT token operations.
132+
"""
133+
try:
134+
from cryptography.hazmat.primitives import serialization
135+
from cryptography.hazmat.primitives.asymmetric import rsa
136+
137+
# Generate RSA key pair
138+
private_key = rsa.generate_private_key( # Future improvement: we could read the next values from env vars
139+
public_exponent=65537,
140+
key_size=2048,
141+
)
142+
143+
# Serialize private key (for signing)
144+
private_pem = private_key.private_bytes(
145+
encoding=serialization.Encoding.PEM,
146+
format=serialization.PrivateFormat.PKCS8,
147+
encryption_algorithm=serialization.NoEncryption(),
148+
).decode("utf-8")
149+
150+
# Serialize public key (for verification)
151+
public_key = private_key.public_key()
152+
public_pem = public_key.public_bytes(
153+
encoding=serialization.Encoding.PEM,
154+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
155+
).decode("utf-8")
156+
157+
logger.debug("JWT RSA key pair generated successfully.")
158+
return private_pem, public_pem
159+
160+
except ImportError as e:
161+
logger.warning(
162+
"The 'cryptography' package is required for automatic JWT key generation."
163+
)
164+
raise e
165+
166+
except Exception as e:
167+
logger.error(
168+
f"Error generating JWT keys: {e}. Please set '{SIGNING_KEY_ENV}' and '{VERIFYING_KEY_ENV}' manually."
169+
)
170+
raise e
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import os
2+
from pathlib import Path
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
from django.conf import settings
7+
8+
import api.apps as api_apps_module
9+
from api.apps import (
10+
ApiConfig,
11+
PRIVATE_KEY_FILE,
12+
PUBLIC_KEY_FILE,
13+
SIGNING_KEY_ENV,
14+
VERIFYING_KEY_ENV,
15+
)
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def reset_keys_initialized(monkeypatch):
20+
"""Ensure per-test clean state for the module-level guard flag."""
21+
monkeypatch.setattr(api_apps_module, "_keys_initialized", False, raising=False)
22+
23+
24+
def _stub_keys():
25+
return (
26+
"""-----BEGIN PRIVATE KEY-----\nPRIVATE\n-----END PRIVATE KEY-----\n""",
27+
"""-----BEGIN PUBLIC KEY-----\nPUBLIC\n-----END PUBLIC KEY-----\n""",
28+
)
29+
30+
31+
def test_generate_jwt_keys_when_missing(monkeypatch, tmp_path):
32+
# Arrange: isolate FS, env, and settings; force generation path
33+
monkeypatch.setattr(
34+
api_apps_module, "KEYS_DIRECTORY", Path(tmp_path), raising=False
35+
)
36+
monkeypatch.delenv(SIGNING_KEY_ENV, raising=False)
37+
monkeypatch.delenv(VERIFYING_KEY_ENV, raising=False)
38+
39+
# Work on a copy of SIMPLE_JWT to avoid mutating the global settings dict for other tests
40+
monkeypatch.setattr(
41+
settings, "SIMPLE_JWT", settings.SIMPLE_JWT.copy(), raising=False
42+
)
43+
monkeypatch.setattr(settings, "TESTING", False, raising=False)
44+
45+
# Avoid dependency on the cryptography package
46+
monkeypatch.setattr(ApiConfig, "_generate_jwt_keys", staticmethod(_stub_keys))
47+
48+
config = ApiConfig("api", api_apps_module)
49+
50+
# Act
51+
config._ensure_crypto_keys()
52+
53+
# Assert: files created with expected content
54+
priv_path = Path(tmp_path) / PRIVATE_KEY_FILE
55+
pub_path = Path(tmp_path) / PUBLIC_KEY_FILE
56+
assert priv_path.is_file()
57+
assert pub_path.is_file()
58+
assert priv_path.read_text() == _stub_keys()[0]
59+
assert pub_path.read_text() == _stub_keys()[1]
60+
61+
# Env vars and Django settings updated
62+
assert os.environ[SIGNING_KEY_ENV] == _stub_keys()[0]
63+
assert os.environ[VERIFYING_KEY_ENV] == _stub_keys()[1]
64+
assert settings.SIMPLE_JWT["SIGNING_KEY"] == _stub_keys()[0]
65+
assert settings.SIMPLE_JWT["VERIFYING_KEY"] == _stub_keys()[1]
66+
67+
68+
def test_ensure_crypto_keys_are_idempotent_within_process(monkeypatch, tmp_path):
69+
# Arrange
70+
monkeypatch.setattr(
71+
api_apps_module, "KEYS_DIRECTORY", Path(tmp_path), raising=False
72+
)
73+
monkeypatch.delenv(SIGNING_KEY_ENV, raising=False)
74+
monkeypatch.delenv(VERIFYING_KEY_ENV, raising=False)
75+
monkeypatch.setattr(
76+
settings, "SIMPLE_JWT", settings.SIMPLE_JWT.copy(), raising=False
77+
)
78+
monkeypatch.setattr(settings, "TESTING", False, raising=False)
79+
80+
mock_generate = MagicMock(side_effect=_stub_keys)
81+
monkeypatch.setattr(ApiConfig, "_generate_jwt_keys", staticmethod(mock_generate))
82+
83+
config = ApiConfig("api", api_apps_module)
84+
85+
# Act: first call should generate, second should be a no-op (guard flag)
86+
config._ensure_crypto_keys()
87+
config._ensure_crypto_keys()
88+
89+
# Assert: generation occurred exactly once
90+
assert mock_generate.call_count == 1
91+
92+
93+
def test_ensure_jwt_keys_uses_existing_files(monkeypatch, tmp_path):
94+
# Arrange: pre-create key files
95+
monkeypatch.setattr(
96+
api_apps_module, "KEYS_DIRECTORY", Path(tmp_path), raising=False
97+
)
98+
monkeypatch.setattr(
99+
settings, "SIMPLE_JWT", settings.SIMPLE_JWT.copy(), raising=False
100+
)
101+
102+
existing_private, existing_public = _stub_keys()
103+
104+
(Path(tmp_path) / PRIVATE_KEY_FILE).write_text(existing_private)
105+
(Path(tmp_path) / PUBLIC_KEY_FILE).write_text(existing_public)
106+
107+
# If generation were called, fail the test
108+
def _fail_generate():
109+
raise AssertionError("_generate_jwt_keys should not be called when files exist")
110+
111+
monkeypatch.setattr(ApiConfig, "_generate_jwt_keys", staticmethod(_fail_generate))
112+
113+
config = ApiConfig("api", api_apps_module)
114+
115+
# Act: call the lower-level method directly to set env/settings from files
116+
config._ensure_jwt_keys()
117+
118+
# Assert
119+
# _read_key_file() strips trailing newlines; environment/settings should reflect stripped content
120+
assert os.environ[SIGNING_KEY_ENV] == existing_private.strip()
121+
assert os.environ[VERIFYING_KEY_ENV] == existing_public.strip()
122+
assert settings.SIMPLE_JWT["SIGNING_KEY"] == existing_private.strip()
123+
assert settings.SIMPLE_JWT["VERIFYING_KEY"] == existing_public.strip()
124+
125+
126+
def test_ensure_crypto_keys_skips_when_env_vars(monkeypatch, tmp_path):
127+
# Arrange: put values in env so the orchestrator doesn't generate
128+
monkeypatch.setattr(
129+
api_apps_module, "KEYS_DIRECTORY", Path(tmp_path), raising=False
130+
)
131+
monkeypatch.setenv(SIGNING_KEY_ENV, "ENV-PRIVATE")
132+
monkeypatch.setenv(VERIFYING_KEY_ENV, "ENV-PUBLIC")
133+
monkeypatch.setattr(
134+
settings, "SIMPLE_JWT", settings.SIMPLE_JWT.copy(), raising=False
135+
)
136+
monkeypatch.setattr(settings, "TESTING", False, raising=False)
137+
138+
called = {"ensure": False}
139+
140+
def _track_call():
141+
called["ensure"] = True
142+
return _stub_keys()
143+
144+
monkeypatch.setattr(ApiConfig, "_generate_jwt_keys", staticmethod(_track_call))
145+
146+
config = ApiConfig("api", api_apps_module)
147+
148+
# Act
149+
config._ensure_crypto_keys()
150+
151+
# Assert: orchestrator did not trigger generation when env present
152+
assert called["ensure"] is False

0 commit comments

Comments
 (0)