Files
vigilcare-clinical/docs/guides/16-phi-encryption-data-protection.md
T

12 KiB

Guide 16: PHI Encryption with Data Protection API

What is PHI and Why Encrypt It?

PHI stands for Protected Health Information — any data that can identify a patient and relates to their health. This includes names, dates of birth, medical record numbers, allergies, and emergency contact details. Healthcare regulations (HIPAA in the US, GDPR in the EU) require that PHI be encrypted at rest — meaning even if someone steals the database files or a backup, they can't read the patient data without the encryption key.

Encryption at rest protects against scenarios like:

  • A database backup is accidentally uploaded to a public S3 bucket
  • A disgruntled employee copies the database files
  • The database server's hard drive is stolen or improperly disposed of

Without encryption, the database stores plaintext: first_name = "Sarah". With encryption: first_name = "CfDJ8Nrq7...long encrypted string...". The application decrypts transparently when reading, and encrypts transparently when writing.

What is the Data Protection API?

.NET's Data Protection API (DPAPI) is a built-in framework for encrypting and decrypting data. It manages encryption keys, handles key rotation, and provides a simple Protect()/Unprotect() interface. You don't need to pick cipher algorithms or manage IVs manually — DPAPI handles the cryptographic details.

Key concepts:

  • IDataProtectionProvider: The factory that creates protectors. Registered with DI at startup.
  • IDataProtector: An instance tied to a specific purpose string. A protector created with purpose "VigilCare.PatientPhi.v1" can only decrypt data that was encrypted with the same purpose. This prevents accidentally decrypting data meant for a different part of the application.
  • Key ring: DPAPI stores encryption keys on disk (configurable path). Keys are automatically rotated and expired on a schedule. Old keys are kept so previously encrypted data can still be decrypted.

How PHI Encryption Works in This Project

Application reads patient.FirstName
        │
        ▼
   EF Core Value Converter
        │  calls crypto.Decrypt(ciphertext)
        ▼
   PhiEncryptionService.Decrypt()
        │  calls _protector.Unprotect(ciphertext)
        ▼
   Returns "Sarah" to the application
Application writes patient.FirstName = "Sarah"
        │
        ▼
   EF Core Value Converter
        │  calls crypto.Encrypt("Sarah")
        ▼
   PhiEncryptionService.Encrypt()
        │  calls _protector.Protect("Sarah")
        ▼
   Stores "CfDJ8Nrq7..." in PostgreSQL

The application code never sees ciphertext — it works with plaintext strings as usual. The encryption and decryption happen inside EF Core value converters (see Guide 4), invisible to the rest of the codebase.


Configuration

PhiEncryptionOptions

public class PhiEncryptionOptions
{
    public const string Section = "PhiEncryption";
    public string ProtectorPurpose { get; set; } = "VigilCare.PatientPhi.v1";
    public string SearchTokenKey { get; set; } = null!;
    public bool LogListAccess { get; set; } = true;
}
Setting Purpose
ProtectorPurpose The purpose string for the data protector. Changing this creates a new encryption scope — old data can't be decrypted with a new purpose without migration.
SearchTokenKey HMAC key for generating searchable name tokens (explained below). Separate from the encryption key.
LogListAccess Whether to log PHI access for list/search operations (compliance auditing).

appsettings.json

{
  "PhiEncryption": {
    "ProtectorPurpose": "VigilCare.PatientPhi.v1",
    "SearchTokenKey": "DEV-ONLY-HMAC-KEY-REPLACE-IN-PRODUCTION-32bytes!!",
    "LogListAccess": true
  },
  "DataProtection": {
    "KeyPath": "./data-protection-keys"
  }
}

Registration in Program.cs

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(
        builder.Configuration["DataProtection:KeyPath"]
            ?? "./data-protection-keys"))
    .SetApplicationName("VigilCareClinical");

PersistKeysToFileSystem stores the encryption keys in a local directory. In production, you'd use PersistKeysToAzureBlobStorage() or PersistKeysToStackExchangeRedis() so keys survive container restarts. SetApplicationName ensures all instances of the application share the same key ring.


The PhiEncryptionService

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 = /* derived from SearchTokenKey */;
    }

    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; // plaintext rows still readable
        return _protector.Unprotect(ciphertext);
    }

    public bool IsEncrypted(string value) =>
        value.StartsWith("CfDJ8", StringComparison.Ordinal) || value.Length > 50;
}

The IsEncrypted check: During the migration from plaintext to encrypted data, some rows may still contain plaintext. The Decrypt method checks if the value looks encrypted (DPAPI-encrypted values start with "CfDJ8" and are much longer than typical names). If not, it returns the value as-is. This lets the application work correctly with a partially-migrated database.

How EF Core Uses the Service

The PatientPhiConverterConfigurator (from Guide 4) wires the service into EF Core value converters:

entity.Property(p => p.FirstName)
    .HasConversion(
        v => crypto.Encrypt(v),     // called on every INSERT/UPDATE
        v => crypto.Decrypt(v));    // called on every SELECT

entity.Property(p => p.DateOfBirth)
    .HasConversion(
        v => crypto.Encrypt(v.ToString("yyyy-MM-dd")),
        v => DateOnly.Parse(crypto.Decrypt(v)));

Encrypted columns: first_name, last_name, date_of_birth, allergies, emergency_contact_name, emergency_contact_phone.


The Search Problem: HMAC Name Tokens

The problem: If names are encrypted, you can't search for patients by name. SQL WHERE first_name LIKE '%Sarah%' doesn't work on ciphertext because each encryption of "Sarah" produces a different ciphertext (DPAPI uses random IVs).

The solution: Store a deterministic, one-way hash of the name in a separate column (name_search_token). To search, hash the query the same way and compare hashes.

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();
}

What is HMAC? HMAC (Hash-based Message Authentication Code) is a keyed hash function. Unlike plain SHA-256 (where anyone can compute the same hash), HMAC requires a secret key. Without the key, an attacker who has the hash can't reverse it to find the name, and can't compute hashes for other names to test against.

Why not just use SHA-256? Plain SHA-256 is vulnerable to rainbow table attacks — an attacker precomputes hashes for common names ("Sarah Smith" → hash, "John Doe" → hash) and compares them against the stored hashes. HMAC with a secret key makes this impossible because the attacker would need the key to compute valid hashes.

The search flow:

  1. User searches for "Sarah Smith"
  2. Application computes HMACSHA256("sarah|smith")"a1b2c3d4..."
  3. SQL query: WHERE name_search_token = 'a1b2c3d4...'
  4. Matching rows are returned, and EF Core's value converter decrypts the actual names

PHI Access Logging

Every access to patient data is logged for compliance auditing:

public class PhiAccessLogService : IPhiAccessLogService
{
    public async Task LogViewAsync(Guid patientId, string resourcePath) =>
        await WriteAsync(PhiAccessType.View, patientId, resourcePath);

    public async Task LogListAsync(string resourcePath, int resultCount,
        string? searchQuery = null)
    {
        var accessType = string.IsNullOrWhiteSpace(searchQuery)
            ? PhiAccessType.List
            : PhiAccessType.Search;
        await WriteAsync(accessType, null, resourcePath, resultCount, searchQuery);
    }

    private async Task WriteAsync(PhiAccessType accessType, Guid? patientId, ...)
    {
        _db.PhiAccessLogs.Add(new PhiAccessLog
        {
            AccessType = accessType,
            PatientId = patientId,
            UserId = _currentUser.UserId!.Value,
            UserDisplayName = _currentUser.DisplayName,
            ResourcePath = resourcePath,
            SearchQueryHash = searchQuery is null ? null : HashQuery(searchQuery),
            IpAddress = _currentUser.IpAddress,
            CorrelationId = /* from middleware */,
            AccessedAt = DateTimeOffset.UtcNow
        });
        await _db.SaveChangesAsync();

        _metrics.PhiAccessLogsTotal.WithLabels(accessType.ToDbString()).Inc();
    }
}

The log records who accessed what, when, from where, and what they searched for. Search queries are hashed (not stored in plaintext) to avoid storing potentially sensitive search terms.


One-Time Migration: EncryptPhiCommand

For existing databases with plaintext patient data, a CLI command encrypts all rows in place:

public static class EncryptPhiCommand
{
    public static async Task RunAsync(IServiceProvider services)
    {
        var patients = await db.Patients.ToListAsync();
        foreach (var p in patients)
        {
            p.NameSearchToken = crypto.ComputeNameSearchToken(p.FirstName, p.LastName);
        }
        await db.SaveChangesAsync();
        Console.WriteLine($"Encrypted {patients.Count} patient records.");
    }
}

Run via: dotnet run -- encrypt-phi

The EF Core value converters handle the actual encryption — loading each patient triggers Decrypt (which passes plaintext through via IsEncrypted check), and saving triggers Encrypt (which encrypts the now-plaintext values). The command also computes NameSearchToken for every patient to enable encrypted name search.


Key Takeaways

  • Encryption at rest protects against data breaches — even if the database is stolen, patient data is unreadable without the encryption keys
  • EF Core value converters make encryption transparent — application code works with plaintext; encryption/decryption happens automatically on every read and write
  • DPAPI handles key management — key generation, rotation, and storage are built-in. You don't manage cryptographic primitives directly.
  • Searchable encryption uses HMAC tokens — deterministic, keyed hashes enable exact-match search on encrypted columns without decrypting every row
  • PHI access logging creates an audit trail — every view, search, and modification of patient data is recorded with user identity, timestamp, and IP address
  • The purpose string isolates encryption scopes — data encrypted with "VigilCare.PatientPhi.v1" can only be decrypted with the same purpose, preventing cross-contamination between different parts of the application