case 4711 - JWT signing key generated by the server; AYANOVA_JWT_SECRET removed

The LAN/IIS and Linux templates shipped a placeholder secret printed in the
manual, and installs that kept it signed logins with a public key; any setting
also invited short or reused values (security review F1). The setting is now
ignored (one warning at boot if still present): the server generates a key once
(48 random bytes) into jwt-signing.key in the data folder (beside the app if
there is none; owner-only on Linux) and reuses it, so sign-ins also survive a
restart where the server used to make a new key each boot (standalone, Linux
desktop). If the file cannot be used, a temporary key is used for that run.

Templates and dev launch configs drop the setting; manual updated (key page
rewritten, setting steps removed from install pages); changelog line.

Checked locally: token forged with the old placeholder 401; key file created
and reused after restart (old token 200); auth and 2FA tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:01:08 -07:00
parent 5bf1f2700a
commit a843bd3132
14 changed files with 61 additions and 113 deletions

View File

@@ -228,27 +228,41 @@ namespace AyaNova
#endregion
#region JWT AUTHENTICATION
//get the key if specified
var secretKey = ServerBootConfig.AYANOVA_JWT_SECRET;
//case 4711: the signing key is no longer a setting (placeholders and weak values could be used to forge tokens);
//it is generated once and kept in a file so sign-ins survive a restart. Deleting the file signs everyone out.
if (ServerBootConfig.AYANOVA_JWT_SECRET_SETTING_PRESENT)
_newLog.LogWarning("AYANOVA_JWT_SECRET is no longer used and can be removed from the configuration: AyaNova generates and keeps its own sign-in key");
//If no key specified make a unique one
//This means the jwt creds won't survive a server reboot
//so in that case users need to specify an AyaNova_JWT_SECRET environment variable
if (string.IsNullOrWhiteSpace(secretKey))
ServerBootConfig.AYANOVA_JWT_SECRET = LoadOrCreateJwtSigningKey(ServerBootConfig.JWT_SIGNING_KEY_FILE_PATH);
string LoadOrCreateJwtSigningKey(string path)
{
_newLog.LogWarning("AYANOVA_JWT_SECRET configuration setting is missing; AyaNova will randomly generate one. Any Users who were logged in when the server restarted will need to login to get a fresh auth token. See manual 'AYANOVA_JWT_SECRET' page for details.");
secretKey = Util.Hasher.GenerateSalt();
}
//WAS "UNLICENSED5G*QQJ8#bQ7$Xr_@sXfHq4"
//If secretKey is less than 32 characters, pad it
if (secretKey.Length < 32)
{
secretKey = secretKey.PadRight(32, '-');
try
{
if (System.IO.File.Exists(path))
{
var existing = System.IO.File.ReadAllText(path).Trim();
if (existing.Length >= 32)
return existing;
_newLog.LogWarning($"Sign-in key file {path} is not valid; generating a new one (users will need to log in again)");
}
var newKey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(48));
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path));
var options = new System.IO.FileStreamOptions { Mode = System.IO.FileMode.Create, Access = System.IO.FileAccess.Write };
if (!OperatingSystem.IsWindows())
options.UnixCreateMode = System.IO.UnixFileMode.UserRead | System.IO.UnixFileMode.UserWrite;
using (var writer = new System.IO.StreamWriter(path, System.Text.Encoding.ASCII, options))
writer.Write(newKey);
_newLog.LogInformation($"Generated a new sign-in key in {path}");
return newKey;
}
catch (Exception ex)
{
_newLog.LogWarning(ex, $"Could not read or create the sign-in key file {path}; using a temporary key for this run (users will need to log in again after a restart)");
return Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(48));
}
}
ServerBootConfig.AYANOVA_JWT_SECRET = secretKey;
var signingKey = new SymmetricSecurityKey(System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET));
_newLog.LogDebug("Authorization");

View File

@@ -59,7 +59,12 @@ namespace AyaNova.Util
internal static long AYANOVA_DEFAULT_TRANSLATION_ID { get; set; } //internal setting set at boot by TranslationBiz::ValidateTranslations
//API
//case 4711: the JWT signing key, set at boot from JWT_SIGNING_KEY_FILE_PATH (generated once); no longer a setting
internal static string AYANOVA_JWT_SECRET { get; set; }
//case 4711: true if the old AYANOVA_JWT_SECRET setting is still present, so boot can say it is ignored
internal static bool AYANOVA_JWT_SECRET_SETTING_PRESENT { get; set; }
//case 4711: where the generated JWT signing key is kept (not a setting, derived from AYANOVA_DATA_PATH, else beside the app)
internal static string JWT_SIGNING_KEY_FILE_PATH { get; set; }
internal static string AYANOVA_USE_URLS { get; set; }
internal static int AYANOVA_REPORT_RENDERING_TIMEOUT { get; set; }
@@ -174,7 +179,8 @@ namespace AyaNova.Util
? "http://*:7575"
: AYANOVA_USE_URLS;
AYANOVA_JWT_SECRET = config.GetValue<string>("AYANOVA_JWT_SECRET");
//case 4711: no longer used, only noted so boot can log that it is ignored
AYANOVA_JWT_SECRET_SETTING_PRESENT = !string.IsNullOrWhiteSpace(config.GetValue<string>("AYANOVA_JWT_SECRET"));
//backdoor back door password superuser reset
AYANOVA_SET_SUPERUSER_PW = config.GetValue<string>("AYANOVA_SET_SUPERUSER_PW");
@@ -270,6 +276,10 @@ namespace AyaNova.Util
(string.IsNullOrWhiteSpace(DataFolderPath))
? string.Empty
: Path.Combine(DataFolderPath, "report-browser");
JWT_SIGNING_KEY_FILE_PATH =
(string.IsNullOrWhiteSpace(DataFolderPath))
? Path.Combine(AppContext.BaseDirectory, "jwt-signing.key")
: Path.Combine(DataFolderPath, "jwt-signing.key");
#endregion server BASICS
}