| | | 1 | | using System.Collections; |
| | | 2 | | using System.Globalization; |
| | | 3 | | using System.Security.Cryptography; |
| | | 4 | | using System.Text; |
| | | 5 | | using System.Text.Json; |
| | | 6 | | |
| | | 7 | | namespace ProjectTemplate.Infrastructure.Data.Auditing; |
| | | 8 | | |
| | | 9 | | internal static class ApplicationAuditValueProtector |
| | | 10 | | { |
| | | 11 | | private const string _maskedValue = "***"; |
| | | 12 | | |
| | | 13 | | internal static bool TryProtect( |
| | | 14 | | IApplicationAuditValuePolicy policy, |
| | | 15 | | Type entityType, |
| | | 16 | | string propertyName, |
| | | 17 | | object? value, |
| | | 18 | | out object protectedValue) |
| | | 19 | | { |
| | 364 | 20 | | ArgumentNullException.ThrowIfNull(policy); |
| | 364 | 21 | | ArgumentNullException.ThrowIfNull(entityType); |
| | 364 | 22 | | ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); |
| | | 23 | | |
| | 364 | 24 | | ApplicationAuditValueDecision decision = policy.Evaluate(entityType, propertyName, value) |
| | 364 | 25 | | ?? throw new InvalidOperationException("The application audit value policy returned no decision."); |
| | | 26 | | |
| | 364 | 27 | | switch (decision.Disposition) |
| | | 28 | | { |
| | | 29 | | case ApplicationAuditValueDisposition.Include: |
| | 258 | 30 | | protectedValue = value ?? string.Empty; |
| | 258 | 31 | | return true; |
| | | 32 | | case ApplicationAuditValueDisposition.Mask: |
| | 86 | 33 | | protectedValue = _maskedValue; |
| | 86 | 34 | | return true; |
| | | 35 | | case ApplicationAuditValueDisposition.Hash: |
| | 6 | 36 | | protectedValue = Hash(value); |
| | 6 | 37 | | return true; |
| | | 38 | | case ApplicationAuditValueDisposition.HmacSha256: |
| | 5 | 39 | | protectedValue = HmacSha256(value, decision.HmacSha256Key); |
| | 4 | 40 | | return true; |
| | | 41 | | case ApplicationAuditValueDisposition.Omit: |
| | 3 | 42 | | protectedValue = string.Empty; |
| | 3 | 43 | | return false; |
| | | 44 | | case ApplicationAuditValueDisposition.Truncate: |
| | 5 | 45 | | protectedValue = Truncate(value, decision.MaximumLength); |
| | 4 | 46 | | return true; |
| | | 47 | | default: |
| | 1 | 48 | | throw new InvalidOperationException($"Unsupported audit value disposition '{decision.Disposition}'."); |
| | | 49 | | } |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | // Unkeyed SHA-256 is an integrity / change-detection digest only. It provides no confidentiality for |
| | | 53 | | // low-entropy values (email addresses, phone numbers, identifiers, booleans, enum names, small numbers), |
| | | 54 | | // which a holder of the audit table can recover by dictionary attack. HmacSha256 is the confidential option. |
| | | 55 | | private static string Hash(object? value) |
| | | 56 | | { |
| | 6 | 57 | | byte[] canonicalValue = Encoding.UTF8.GetBytes(ToCanonicalString(value)); |
| | 6 | 58 | | byte[] hash = SHA256.HashData(canonicalValue); |
| | 6 | 59 | | return Convert.ToHexString(hash); |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | private static string HmacSha256(object? value, string? key) |
| | | 63 | | { |
| | 5 | 64 | | if (string.IsNullOrWhiteSpace(key)) |
| | | 65 | | { |
| | 1 | 66 | | throw new ArgumentException("HMAC-SHA-256 audit values require a non-empty key.", nameof(key)); |
| | | 67 | | } |
| | | 68 | | |
| | 4 | 69 | | byte[] canonicalValue = Encoding.UTF8.GetBytes(ToCanonicalString(value)); |
| | 4 | 70 | | byte[] keyBytes = Encoding.UTF8.GetBytes(key); |
| | 4 | 71 | | byte[] hash = HMACSHA256.HashData(keyBytes, canonicalValue); |
| | 4 | 72 | | return Convert.ToHexString(hash); |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | // Produces a culture-invariant representation that is distinct for distinct values. Types whose |
| | | 76 | | // Object.ToString() is only the type name (byte[], collections) are expanded, so change detection on |
| | | 77 | | // binary and collection columns does not collapse to a single constant digest. |
| | | 78 | | internal static string ToCanonicalString(object? value) |
| | | 79 | | { |
| | 25 | 80 | | return value switch |
| | 25 | 81 | | { |
| | 2 | 82 | | null => string.Empty, |
| | 12 | 83 | | string text => text, |
| | 5 | 84 | | byte[] bytes => Convert.ToHexString(bytes), |
| | 0 | 85 | | ReadOnlyMemory<byte> memory => Convert.ToHexString(memory.Span), |
| | 0 | 86 | | Memory<byte> memory => Convert.ToHexString(memory.Span), |
| | 2 | 87 | | DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture), |
| | 0 | 88 | | DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture), |
| | 1 | 89 | | IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), |
| | 3 | 90 | | IEnumerable sequence => JsonSerializer.Serialize(sequence.Cast<object?>().Select(ToCanonicalString).ToArray( |
| | 0 | 91 | | _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, |
| | 25 | 92 | | }; |
| | | 93 | | } |
| | | 94 | | |
| | | 95 | | private static string Truncate(object? value, int? maximumLength) |
| | | 96 | | { |
| | 5 | 97 | | if (maximumLength is null or <= 0) |
| | | 98 | | { |
| | 1 | 99 | | throw new InvalidOperationException("Truncated audit values require a positive maximum length."); |
| | | 100 | | } |
| | | 101 | | |
| | 4 | 102 | | string text = ToCanonicalString(value); |
| | 4 | 103 | | if (text.Length <= maximumLength.Value) |
| | | 104 | | { |
| | 1 | 105 | | return text; |
| | | 106 | | } |
| | | 107 | | |
| | 3 | 108 | | int truncationLength = maximumLength.Value; |
| | 3 | 109 | | if (char.IsHighSurrogate(text[truncationLength - 1]) |
| | 3 | 110 | | && char.IsLowSurrogate(text[truncationLength])) |
| | | 111 | | { |
| | 1 | 112 | | truncationLength--; |
| | | 113 | | } |
| | | 114 | | |
| | 3 | 115 | | return text[..truncationLength]; |
| | | 116 | | } |
| | | 117 | | } |