Skip to content

Commit 9c8f89a

Browse files
committed
security: harden config, argon2 defaults, and non-blocking incident logging
1 parent a31fb18 commit 9c8f89a

File tree

6 files changed

+153
-20
lines changed

6 files changed

+153
-20
lines changed

SecurityHelperLibrary.Sample/Program.cs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,36 @@
4242

4343
string jwtIssuer = builder.Configuration["SecurityAudit:Jwt:Issuer"] ?? "SecurityHelperLibrary.Sample";
4444
string jwtAudience = builder.Configuration["SecurityAudit:Jwt:Audience"] ?? "SecurityHelperLibrary.Sample.Admin";
45-
string jwtSigningKey = builder.Configuration["SecurityAudit:Jwt:SigningKey"] ?? "change-this-signing-key-at-least-32-characters";
46-
byte[] jwtSigningKeyBytes = Encoding.UTF8.GetBytes(jwtSigningKey);
45+
string jwtSigningKeyBase64 = builder.Configuration["SecurityAudit:Jwt:SigningKeyBase64"];
46+
if (string.IsNullOrWhiteSpace(jwtSigningKeyBase64) || jwtSigningKeyBase64.StartsWith("__SET_VIA_ENV", StringComparison.Ordinal))
47+
{
48+
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKeyBase64 must be provided via environment/secret store.");
49+
}
50+
51+
byte[] jwtSigningKeyBytes;
52+
try
53+
{
54+
jwtSigningKeyBytes = Convert.FromBase64String(jwtSigningKeyBase64);
55+
}
56+
catch (FormatException)
57+
{
58+
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKeyBase64 must be valid Base64.");
59+
}
60+
4761
if (jwtSigningKeyBytes.Length < 32)
4862
{
49-
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKey must be at least 32 characters.");
63+
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKeyBase64 must decode to at least 32 bytes.");
5064
}
5165

66+
string connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
67+
if (string.IsNullOrWhiteSpace(connectionString) || connectionString.StartsWith("__SET_VIA_ENV", StringComparison.Ordinal))
68+
{
69+
throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be provided via environment/secret store.");
70+
}
71+
72+
byte[] validationSigningKey = (byte[])jwtSigningKeyBytes.Clone();
73+
Array.Clear(jwtSigningKeyBytes, 0, jwtSigningKeyBytes.Length);
74+
5275
builder.Services
5376
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
5477
.AddJwtBearer(options =>
@@ -61,7 +84,7 @@
6184
ValidateLifetime = true,
6285
ValidIssuer = jwtIssuer,
6386
ValidAudience = jwtAudience,
64-
IssuerSigningKey = new SymmetricSecurityKey(jwtSigningKeyBytes),
87+
IssuerSigningKey = new SymmetricSecurityKey(validationSigningKey),
6588
ClockSkew = TimeSpan.Zero
6689
};
6790
});
@@ -72,9 +95,6 @@
7295
// SQLite connection string points to "app.db" in the application directory
7396
// This makes it easy to develop locally without setting up a database server
7497
// In production, switch to SQL Server, PostgreSQL, Azure SQL, etc.
75-
string connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
76-
?? "Data Source=app.db"; // Fallback to local SQLite if not configured
77-
7898
builder.Services.AddDbContext<ApplicationDbContext>(options =>
7999
{
80100
// Use SQLite provider
@@ -100,15 +120,24 @@
100120
builder.Services.AddSingleton<ISecurityIncidentStore, InMemorySecurityIncidentStore>();
101121
builder.Services.AddScoped<ISecurityHelper>(serviceProvider =>
102122
{
123+
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
103124
var incidentStore = serviceProvider.GetRequiredService<ISecurityIncidentStore>();
104125
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
105126
var incidentLogger = loggerFactory.CreateLogger("SecurityIncidents");
127+
var argon2Options = new SecurityHelperOptions
128+
{
129+
Argon2DefaultIterations = configuration.GetValue<int?>("SecurityHelper:Argon2Defaults:Iterations") ?? 4,
130+
Argon2DefaultMemoryKb = configuration.GetValue<int?>("SecurityHelper:Argon2Defaults:MemoryKb") ?? 131072,
131+
Argon2DefaultDegreeOfParallelism = configuration.GetValue<int?>("SecurityHelper:Argon2Defaults:DegreeOfParallelism") ?? 4,
132+
Argon2DefaultHashLength = configuration.GetValue<int?>("SecurityHelper:Argon2Defaults:HashLength") ?? 32
133+
};
106134

107135
return new SecurityHelper(incidentCode =>
108136
{
137+
string structuredIncident = $"SEC_EVT|source=SecurityHelper|code={incidentCode}|ts={DateTimeOffset.UtcNow:O}";
109138
incidentStore.Add(incidentCode);
110-
incidentLogger.LogWarning("Security incident detected: {IncidentCode}", incidentCode);
111-
});
139+
incidentLogger.LogWarning("Security incident detected: {Incident}", structuredIncident);
140+
}, argon2Options);
112141
});
113142

114143
// Add CORS (Cross-Origin Resource Sharing) for frontend access

SecurityHelperLibrary.Sample/Services/JwtTokenService.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,24 @@ public JwtTokenResult CreateToken(User user)
6464
claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
6565
}
6666

67-
string signingKey = _configuration["SecurityAudit:Jwt:SigningKey"] ?? "change-this-signing-key-at-least-32-characters";
68-
byte[] keyBytes = Encoding.UTF8.GetBytes(signingKey);
67+
string signingKeyBase64 = _configuration["SecurityAudit:Jwt:SigningKeyBase64"];
68+
if (string.IsNullOrWhiteSpace(signingKeyBase64) || signingKeyBase64.StartsWith("__SET_VIA_ENV", StringComparison.Ordinal))
69+
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKeyBase64 must be provided via environment/secret store.");
70+
71+
byte[] keyBytes;
72+
try
73+
{
74+
keyBytes = Convert.FromBase64String(signingKeyBase64);
75+
}
76+
catch (FormatException)
77+
{
78+
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKeyBase64 must be valid Base64.");
79+
}
6980

7081
if (keyBytes.Length < 32)
7182
{
7283
SecureZero(keyBytes);
73-
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKey must be at least 32 characters.");
84+
throw new InvalidOperationException("SecurityAudit:Jwt:SigningKeyBase64 must decode to at least 32 bytes.");
7485
}
7586

7687
try

SecurityHelperLibrary.Sample/appsettings.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,26 @@
77
},
88
"AllowedHosts": "*",
99
"ConnectionStrings": {
10-
"DefaultConnection": "Data Source=app.db"
10+
"DefaultConnection": "__SET_VIA_ENV__"
1111
},
1212
"SecurityAudit": {
1313
"Jwt": {
1414
"Issuer": "SecurityHelperLibrary.Sample",
1515
"Audience": "SecurityHelperLibrary.Sample.Admin",
16-
"SigningKey": "change-this-signing-key-at-least-32-characters",
16+
"SigningKeyBase64": "__SET_VIA_ENV_BASE64__",
1717
"AccessTokenMinutes": 60
1818
},
1919
"AdminUsers": [
2020
"admin",
2121
"admin@example.com"
2222
]
23+
},
24+
"SecurityHelper": {
25+
"Argon2Defaults": {
26+
"Iterations": 4,
27+
"MemoryKb": 131072,
28+
"DegreeOfParallelism": 4,
29+
"HashLength": 32
30+
}
2331
}
2432
}

SecurityHelperLibrary.Tests/SecurityHelperLibraryPentestTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Linq;
55
using System.Reflection;
66
using System.Security.Cryptography;
7+
using System.Threading;
78
using SecurityHelperLibrary;
89
using Xunit;
910

@@ -46,8 +47,9 @@ public void SecurityIncident_LogsInternalReasonCode_WithoutLeakingToCaller()
4647
helperWithLogger.VerifyPasswordWithPBKDF2("wrong", "invalid_format"));
4748

4849
Assert.Equal(GenericError, ex.Message);
50+
SpinWait.SpinUntil(() => incidents.Count > 0, 250);
4951
Assert.NotEmpty(incidents);
50-
Assert.Contains(incidents, code => code.StartsWith("PBKDF2_", StringComparison.Ordinal));
52+
Assert.Contains(incidents, code => code.IndexOf("code=PBKDF2_", StringComparison.Ordinal) >= 0);
5153
Assert.DoesNotContain("invalid_format", string.Join(";", incidents));
5254
}
5355

SecurityHelperLibrary/SecurityHelperLibrary.cs

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Security.Cryptography;
33
using System.Text;
44
using System.ComponentModel;
5+
using System.Threading;
56
using Isopoh.Cryptography.Argon2;
67

78
namespace SecurityHelperLibrary
@@ -16,17 +17,62 @@ public class SecurityHelper : ISecurityHelper
1617
private const int MinHashLengthBytes = 16;
1718
private const int MinArgon2Iterations = 3;
1819
private const int MinArgon2MemoryKb = 65536;
20+
private const int BaselineArgon2Iterations = 4;
21+
private const int BaselineArgon2MemoryKb = 131072;
22+
private const int BaselineArgon2DegreeOfParallelism = 4;
23+
private const int BaselineArgon2HashLength = 32;
1924
private const int MaxArgon2DegreeOfParallelism = 64;
2025
private readonly Action<string> _securityIncidentLogger;
26+
private readonly int _defaultArgon2Iterations;
27+
private readonly int _defaultArgon2MemoryKb;
28+
private readonly int _defaultArgon2DegreeOfParallelism;
29+
private readonly int _defaultArgon2HashLength;
2130

2231
public SecurityHelper()
23-
: this(null)
32+
: this((Action<string>)null)
2433
{
2534
}
2635

2736
public SecurityHelper(Action<string> securityIncidentLogger)
37+
: this(securityIncidentLogger, null)
38+
{
39+
}
40+
41+
public SecurityHelper(SecurityHelperOptions options)
42+
: this(null, options)
43+
{
44+
}
45+
46+
public SecurityHelper(Action<string> securityIncidentLogger, SecurityHelperOptions options)
2847
{
2948
_securityIncidentLogger = securityIncidentLogger;
49+
if (options == null)
50+
{
51+
_defaultArgon2Iterations = BaselineArgon2Iterations;
52+
_defaultArgon2MemoryKb = BaselineArgon2MemoryKb;
53+
_defaultArgon2DegreeOfParallelism = BaselineArgon2DegreeOfParallelism;
54+
_defaultArgon2HashLength = BaselineArgon2HashLength;
55+
return;
56+
}
57+
58+
_defaultArgon2Iterations = options.Argon2DefaultIterations >= MinArgon2Iterations
59+
? options.Argon2DefaultIterations
60+
: MinArgon2Iterations;
61+
62+
_defaultArgon2MemoryKb = options.Argon2DefaultMemoryKb >= MinArgon2MemoryKb
63+
? options.Argon2DefaultMemoryKb
64+
: MinArgon2MemoryKb;
65+
66+
_defaultArgon2DegreeOfParallelism = options.Argon2DefaultDegreeOfParallelism >= 1
67+
? options.Argon2DefaultDegreeOfParallelism
68+
: 1;
69+
70+
if (_defaultArgon2DegreeOfParallelism > MaxArgon2DegreeOfParallelism)
71+
_defaultArgon2DegreeOfParallelism = MaxArgon2DegreeOfParallelism;
72+
73+
_defaultArgon2HashLength = options.Argon2DefaultHashLength >= MinHashLengthBytes
74+
? options.Argon2DefaultHashLength
75+
: BaselineArgon2HashLength;
3076
}
3177

3278
// --- IMMUTABLE WORKING METHODS ---
@@ -234,6 +280,7 @@ private static HashAlgorithm GetHashAlgorithm(HashAlgorithmName hashAlgorithm)
234280
#endif
235281
public string HashPasswordWithArgon2(string password, string salt, int iterations = 4, int memoryKb = 131072, int degreeOfParallelism = 4, int hashLength = 32)
236282
{
283+
ApplyArgon2Defaults(ref iterations, ref memoryKb, ref degreeOfParallelism, ref hashLength);
237284
#if NET6_0_OR_GREATER
238285
if (string.IsNullOrEmpty(password))
239286
throw new ArgumentException("Password cannot be null or empty.", nameof(password));
@@ -290,6 +337,7 @@ public string HashPasswordWithArgon2(string password, string salt, int iteration
290337
/// </summary>
291338
public string HashPasswordWithArgon2(ReadOnlySpan<char> password, string salt, int iterations = 4, int memoryKb = 131072, int degreeOfParallelism = 4, int hashLength = 32)
292339
{
340+
ApplyArgon2Defaults(ref iterations, ref memoryKb, ref degreeOfParallelism, ref hashLength);
293341
if (password.Length == 0)
294342
throw new ArgumentException("Password cannot be empty.", nameof(password));
295343
if (string.IsNullOrWhiteSpace(salt))
@@ -689,17 +737,42 @@ private CryptographicException CreateInvalidSecurityParametersException(string i
689737
return new CryptographicException(GenericSecurityErrorMessage);
690738
}
691739

740+
private void ApplyArgon2Defaults(ref int iterations, ref int memoryKb, ref int degreeOfParallelism, ref int hashLength)
741+
{
742+
if (iterations == BaselineArgon2Iterations)
743+
iterations = _defaultArgon2Iterations;
744+
745+
if (memoryKb == BaselineArgon2MemoryKb)
746+
memoryKb = _defaultArgon2MemoryKb;
747+
748+
if (degreeOfParallelism == BaselineArgon2DegreeOfParallelism)
749+
degreeOfParallelism = _defaultArgon2DegreeOfParallelism;
750+
751+
if (hashLength == BaselineArgon2HashLength)
752+
hashLength = _defaultArgon2HashLength;
753+
}
754+
692755
private void LogSecurityIncident(string incidentCode, Exception exception)
693756
{
694757
if (_securityIncidentLogger == null)
695758
return;
696759

760+
string payload = exception == null
761+
? $"SEC_EVT|code={incidentCode}|exception=None"
762+
: $"SEC_EVT|code={incidentCode}|exception={exception.GetType().Name}";
763+
697764
try
698765
{
699-
if (exception == null)
700-
_securityIncidentLogger.Invoke(incidentCode);
701-
else
702-
_securityIncidentLogger.Invoke($"{incidentCode}|{exception.GetType().Name}");
766+
ThreadPool.QueueUserWorkItem(_ =>
767+
{
768+
try
769+
{
770+
_securityIncidentLogger.Invoke(payload);
771+
}
772+
catch
773+
{
774+
}
775+
});
703776
}
704777
catch
705778
{
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace SecurityHelperLibrary
2+
{
3+
public sealed class SecurityHelperOptions
4+
{
5+
public int Argon2DefaultIterations { get; set; } = 4;
6+
public int Argon2DefaultMemoryKb { get; set; } = 131072;
7+
public int Argon2DefaultDegreeOfParallelism { get; set; } = 4;
8+
public int Argon2DefaultHashLength { get; set; } = 32;
9+
}
10+
}

0 commit comments

Comments
 (0)