begin: PHI Column Encryption

This commit is contained in:
voltsrage
2026-06-22 21:18:36 +08:00
parent 518cc5b99a
commit 46db694820
21 changed files with 3177 additions and 6 deletions
@@ -0,0 +1,7 @@
public interface IPhiAccessLogService
{
Task LogViewAsync(Guid patientId, string resourcePath);
Task LogListAsync(string resourcePath, int resultCount, string? searchQuery = null);
Task LogCreateAsync(Guid patientId, string resourcePath);
Task LogUpdateAsync(Guid patientId, string resourcePath);
}
@@ -0,0 +1,7 @@
public interface IPhiEncryptionService
{
string Encrypt(string plaintext);
string Decrypt(string ciphertext);
string ComputeNameSearchToken(string firstName, string lastName);
bool IsEncrypted(string value);
}
@@ -8,17 +8,26 @@ public class PatientService : IPatientService
private readonly IExternalIdentifierService _identifiers;
private readonly IAuditService _audit;
private readonly PatientOptions _options;
private readonly IPhiEncryptionService _crypto;
private readonly IPhiAccessLogService _phiAccess;
private readonly IHttpContextAccessor _http;
public PatientService(
AppDbContext db,
IExternalIdentifierService identifiers,
IAuditService audit,
IOptions<PatientOptions> options)
IOptions<PatientOptions> options,
IPhiEncryptionService crypto,
IPhiAccessLogService phiAccess,
IHttpContextAccessor http)
{
_db = db;
_identifiers = identifiers;
_audit = audit;
_options = options.Value;
_crypto = crypto;
_phiAccess = phiAccess;
_http = http;
}
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
@@ -37,6 +46,7 @@ public class PatientService : IPatientService
EmergencyContactPhone = req.EmergencyContactPhone,
CreatedAt = DateTimeOffset.UtcNow
};
SetNameSearchToken(patient);
_db.Patients.Add(patient);
try
@@ -260,4 +270,10 @@ public class PatientService : IPatientService
.SingleAsync();
return $"{_options.MrnPrefix}-{seq.ToString($"D{_options.MrnDigits}")}";
}
private void SetNameSearchToken(Patient patient)
{
patient.NameSearchToken = _crypto.ComputeNameSearchToken(
patient.FirstName, patient.LastName);
}
}
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
public class PhiAccessLogService : IPhiAccessLogService
{
private readonly AppDbContext _db;
private readonly ICurrentUserService _currentUser;
private readonly IHttpContextAccessor _http;
private readonly PhiEncryptionOptions _options;
public PhiAccessLogService(
AppDbContext db,
ICurrentUserService currentUser,
IHttpContextAccessor http,
IOptions<PhiEncryptionOptions> options)
{
_db = db;
_currentUser = currentUser;
_http = http;
_options = options.Value;
}
public async Task LogViewAsync(Guid patientId, string resourcePath) =>
await WriteAsync(PhiAccessType.View, patientId, resourcePath);
public async Task LogCreateAsync(Guid patientId, string resourcePath) =>
await WriteAsync(PhiAccessType.Create, patientId, resourcePath);
public async Task LogUpdateAsync(Guid patientId, string resourcePath) =>
await WriteAsync(PhiAccessType.Update, patientId, resourcePath);
public async Task LogListAsync(string resourcePath, int resultCount, string? searchQuery = null)
{
if (!_options.LogListAccess)
return;
var accessType = string.IsNullOrWhiteSpace(searchQuery)
? PhiAccessType.List
: PhiAccessType.Search;
await WriteAsync(accessType, null, resourcePath, resultCount, searchQuery);
}
private async Task WriteAsync(
PhiAccessType accessType,
Guid? patientId,
string resourcePath,
int? resultCount = null,
string? searchQuery = null)
{
if (!_currentUser.IsAuthenticated || _currentUser.UserId is null)
return; // machine/integration paths may skip — Phase 31 Integration role should still auth
_db.PhiAccessLogs.Add(new PhiAccessLog
{
Id = Guid.NewGuid(),
AccessType = accessType,
PatientId = patientId,
UserId = _currentUser.UserId.Value,
UserDisplayName = _currentUser.DisplayName ?? _currentUser.Username ?? "Unknown",
ResourcePath = resourcePath,
SearchQueryHash = searchQuery is null ? null : HashQuery(searchQuery),
ResultCount = resultCount,
IpAddress = _currentUser.IpAddress,
CorrelationId = _http.HttpContext?.Items["CorrelationId"]?.ToString(),
AccessedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
}
private static string HashQuery(string query)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(query.Trim().ToLowerInvariant()));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
public class PhiEncryptionService : IPhiEncryptionService
{
private readonly IDataProtector _protector;
private readonly byte[] _searchKey;
public PhiEncryptionService(
IDataProtectionProvider provider,
IOptions<PhiEncryptionOptions> options)
{
var opts = options.Value;
_protector = provider.CreateProtector(opts.ProtectorPurpose);
_searchKey = Convert.FromBase64String(
Convert.ToBase64String(Encoding.UTF8.GetBytes(opts.SearchTokenKey))[..44]); // normalize dev key
}
public string Encrypt(string plaintext)
{
if (string.IsNullOrEmpty(plaintext))
return plaintext;
return _protector.Protect(plaintext);
}
public string Decrypt(string ciphertext)
{
if (string.IsNullOrEmpty(ciphertext))
return ciphertext;
if (!IsEncrypted(ciphertext))
return ciphertext; // migration transition: plaintext rows still readable
return _protector.Unprotect(ciphertext);
}
public bool IsEncrypted(string value) =>
value.StartsWith("CfDJ8", StringComparison.Ordinal) || // DataProtection prefix
value.Length > 50; // heuristic for protected payloads
public string ComputeNameSearchToken(string firstName, string lastName)
{
var normalized = $"{firstName.Trim().ToLowerInvariant()}|{lastName.Trim().ToLowerInvariant()}";
using var hmac = new HMACSHA256(_searchKey);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}