| | | 1 | | using LOCKnet.Core.Crypto; |
| | | 2 | | using LOCKnet.Core.DataAbstractions; |
| | | 3 | | using LOCKnet.Core.Security; |
| | | 4 | | using System.Security; |
| | | 5 | | |
| | | 6 | | namespace LOCKnet.Core.Services; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Implementierung von <see cref="ICredentialService"/>. |
| | | 10 | | /// Kombiniert <see cref="ISessionManager"/> (Session-Key), <see cref="IEncryptionService"/> (AES-GCM) |
| | | 11 | | /// und <see cref="ICredentialRepository"/> (Persistenz). |
| | | 12 | | /// </summary> |
| | | 13 | | public sealed class CredentialService : ICredentialService |
| | | 14 | | { |
| | | 15 | | private readonly ICredentialRepository _repo; |
| | | 16 | | private readonly IMasterKeyRepository _masterKeyRepo; |
| | | 17 | | private readonly ICredentialEnvelopeService _credentialEnvelope; |
| | | 18 | | private readonly ISessionManager _session; |
| | | 19 | | private readonly ISecureStringService _secureStr; |
| | | 20 | | |
| | | 21 | | /// <summary> |
| | | 22 | | /// Initialisiert eine neue Instanz von <see cref="CredentialService"/>. |
| | | 23 | | /// </summary> |
| | 55 | 24 | | public CredentialService( |
| | 55 | 25 | | ICredentialRepository repo, |
| | 55 | 26 | | IMasterKeyRepository masterKeyRepo, |
| | 55 | 27 | | IEncryptionService encryption, |
| | 55 | 28 | | ICredentialEnvelopeService credentialEnvelope, |
| | 55 | 29 | | ISessionManager session, |
| | 55 | 30 | | ISecureStringService secureStr) |
| | 55 | 31 | | { |
| | 55 | 32 | | ArgumentNullException.ThrowIfNull(repo); |
| | 55 | 33 | | ArgumentNullException.ThrowIfNull(masterKeyRepo); |
| | 55 | 34 | | ArgumentNullException.ThrowIfNull(credentialEnvelope); |
| | 55 | 35 | | ArgumentNullException.ThrowIfNull(session); |
| | 55 | 36 | | ArgumentNullException.ThrowIfNull(secureStr); |
| | 55 | 37 | | _repo = repo; |
| | 55 | 38 | | _masterKeyRepo = masterKeyRepo; |
| | 55 | 39 | | _credentialEnvelope = credentialEnvelope; |
| | 55 | 40 | | _session = session; |
| | 55 | 41 | | _secureStr = secureStr; |
| | 55 | 42 | | } |
| | | 43 | | |
| | | 44 | | /// <inheritdoc/> |
| | | 45 | | public void Add(string title, string? username, SecureString password, string? url = null, string? notes = null, strin |
| | 26 | 46 | | { |
| | 26 | 47 | | ArgumentException.ThrowIfNullOrWhiteSpace(title); |
| | 24 | 48 | | ArgumentNullException.ThrowIfNull(password); |
| | | 49 | | |
| | 24 | 50 | | var key = RequireSessionKey(); |
| | 23 | 51 | | var header = RequireCurrentHeader(); |
| | 23 | 52 | | var passwordBytes = _secureStr.ToByteArray(password); |
| | | 53 | | try |
| | 23 | 54 | | { |
| | 23 | 55 | | var record = new CredentialRecord |
| | 23 | 56 | | { |
| | 23 | 57 | | Title = title, |
| | 23 | 58 | | Username = username, |
| | 23 | 59 | | CredentialUuid = Guid.NewGuid().ToString("N"), |
| | 23 | 60 | | SecretFormatVersion = _credentialEnvelope.CurrentVersion, |
| | 23 | 61 | | MetadataFormatVersion = _credentialEnvelope.CurrentMetadataVersion, |
| | 23 | 62 | | Url = url, |
| | 23 | 63 | | Notes = notes, |
| | 23 | 64 | | IconKey = iconKey, |
| | 23 | 65 | | CredentialType = credentialType, |
| | 23 | 66 | | CreatedAt = DateTime.UtcNow, |
| | 23 | 67 | | UpdatedAt = DateTime.UtcNow |
| | 23 | 68 | | }; |
| | 23 | 69 | | record.EncryptedPassword = _credentialEnvelope.Encrypt(passwordBytes, key, record, header.FormatVersion); |
| | 23 | 70 | | record.EncryptedMetadata = _credentialEnvelope.EncryptMetadata(record, key, header.FormatVersion); |
| | 23 | 71 | | _repo.Add(ToPersistedRecord(record)); |
| | 23 | 72 | | } |
| | | 73 | | finally |
| | 23 | 74 | | { |
| | 23 | 75 | | _secureStr.ZeroMemory(key); |
| | 23 | 76 | | _secureStr.ZeroMemory(passwordBytes); |
| | 23 | 77 | | } |
| | 23 | 78 | | } |
| | | 79 | | |
| | | 80 | | /// <inheritdoc/> |
| | | 81 | | public IReadOnlyList<CredentialRecord> GetAll() |
| | 25 | 82 | | { |
| | 25 | 83 | | var key = RequireSessionKey(); |
| | 23 | 84 | | var header = RequireCurrentHeader(); |
| | | 85 | | try |
| | 23 | 86 | | { |
| | 23 | 87 | | return _repo.GetAll() |
| | 23 | 88 | | .Select(record => MaterializeRecord(record, key, header.FormatVersion)) |
| | 23 | 89 | | .ToList(); |
| | | 90 | | } |
| | | 91 | | finally |
| | 23 | 92 | | { |
| | 23 | 93 | | _secureStr.ZeroMemory(key); |
| | 23 | 94 | | } |
| | 21 | 95 | | } |
| | | 96 | | |
| | | 97 | | /// <inheritdoc/> |
| | | 98 | | public SecureString? GetPassword(int id) |
| | 10 | 99 | | { |
| | 10 | 100 | | var key = RequireSessionKey(); |
| | 7 | 101 | | var header = RequireCurrentHeader(); |
| | | 102 | | try |
| | 7 | 103 | | { |
| | 7 | 104 | | var record = _repo.GetById(id); |
| | 9 | 105 | | if (record is null) return null; |
| | | 106 | | |
| | 5 | 107 | | var decrypted = _credentialEnvelope.Decrypt(record, key, header.FormatVersion); |
| | | 108 | | try |
| | 3 | 109 | | { |
| | 3 | 110 | | return _secureStr.FromByteArray(decrypted); |
| | | 111 | | } |
| | | 112 | | finally |
| | 3 | 113 | | { |
| | 3 | 114 | | _secureStr.ZeroMemory(decrypted); |
| | 3 | 115 | | } |
| | | 116 | | } |
| | | 117 | | finally |
| | 7 | 118 | | { |
| | 7 | 119 | | _secureStr.ZeroMemory(key); |
| | 7 | 120 | | } |
| | 5 | 121 | | } |
| | | 122 | | |
| | | 123 | | /// <inheritdoc/> |
| | | 124 | | public void Update(int id, string title, string? username, SecureString? newPassword, string? url = null, string? note |
| | 7 | 125 | | { |
| | 7 | 126 | | ArgumentException.ThrowIfNullOrWhiteSpace(title); |
| | | 127 | | |
| | 7 | 128 | | var key = RequireSessionKey(); |
| | 5 | 129 | | var header = RequireCurrentHeader(); |
| | | 130 | | try |
| | 5 | 131 | | { |
| | 5 | 132 | | var existing = _repo.GetById(id) |
| | 5 | 133 | | ?? throw new InvalidOperationException($"Credential mit ID {id} nicht gefunden."); |
| | | 134 | | |
| | | 135 | | byte[] encryptedPassword; |
| | 4 | 136 | | var credentialUuid = EnsureCredentialUuid(existing.CredentialUuid); |
| | 4 | 137 | | var secretFormatVersion = existing.SecretFormatVersion; |
| | 4 | 138 | | if (newPassword is not null) |
| | 1 | 139 | | { |
| | 1 | 140 | | var passwordBytes = _secureStr.ToByteArray(newPassword); |
| | | 141 | | try |
| | 1 | 142 | | { |
| | 1 | 143 | | secretFormatVersion = _credentialEnvelope.CurrentVersion; |
| | 1 | 144 | | var encryptedRecord = new CredentialRecord |
| | 1 | 145 | | { |
| | 1 | 146 | | Id = id, |
| | 1 | 147 | | CredentialUuid = credentialUuid, |
| | 1 | 148 | | CredentialType = credentialType, |
| | 1 | 149 | | }; |
| | 1 | 150 | | encryptedPassword = _credentialEnvelope.Encrypt(passwordBytes, key, encryptedRecord, header.FormatVersion); |
| | 1 | 151 | | } |
| | | 152 | | finally |
| | 1 | 153 | | { |
| | 1 | 154 | | _secureStr.ZeroMemory(passwordBytes); |
| | 1 | 155 | | } |
| | 1 | 156 | | } |
| | | 157 | | else |
| | 3 | 158 | | { |
| | 3 | 159 | | encryptedPassword = existing.EncryptedPassword; |
| | 3 | 160 | | } |
| | | 161 | | |
| | 4 | 162 | | var materialized = new CredentialRecord |
| | 4 | 163 | | { |
| | 4 | 164 | | Id = id, |
| | 4 | 165 | | Title = title, |
| | 4 | 166 | | Username = username, |
| | 4 | 167 | | EncryptedPassword = encryptedPassword, |
| | 4 | 168 | | EncryptedMetadata = [], |
| | 4 | 169 | | CredentialUuid = credentialUuid, |
| | 4 | 170 | | SecretFormatVersion = secretFormatVersion, |
| | 4 | 171 | | MetadataFormatVersion = _credentialEnvelope.CurrentMetadataVersion, |
| | 4 | 172 | | Url = url, |
| | 4 | 173 | | Notes = notes, |
| | 4 | 174 | | IconKey = iconKey, |
| | 4 | 175 | | CredentialType = credentialType, |
| | 4 | 176 | | CreatedAt = existing.CreatedAt, |
| | 4 | 177 | | UpdatedAt = DateTime.UtcNow |
| | 4 | 178 | | }; |
| | 4 | 179 | | materialized.EncryptedMetadata = _credentialEnvelope.EncryptMetadata(materialized, key, header.FormatVersion); |
| | 4 | 180 | | _repo.Update(ToPersistedRecord(materialized)); |
| | 4 | 181 | | } |
| | | 182 | | finally |
| | 5 | 183 | | { |
| | 5 | 184 | | _secureStr.ZeroMemory(key); |
| | 5 | 185 | | } |
| | 4 | 186 | | } |
| | | 187 | | |
| | | 188 | | /// <inheritdoc/> |
| | | 189 | | public void Remove(int id) |
| | 4 | 190 | | { |
| | 4 | 191 | | RequireUnlocked(); |
| | 2 | 192 | | _repo.Remove(id); |
| | 2 | 193 | | } |
| | | 194 | | |
| | | 195 | | // ── Helpers ─────────────────────────────────────────────────────────────── |
| | | 196 | | |
| | | 197 | | private byte[] RequireSessionKey() |
| | 66 | 198 | | { |
| | 66 | 199 | | var key = _session.GetSessionKey(); |
| | 66 | 200 | | if (key is null) |
| | 8 | 201 | | throw new InvalidOperationException("Sitzung ist gesperrt. Bitte zuerst entsperren."); |
| | 58 | 202 | | return key; |
| | 58 | 203 | | } |
| | | 204 | | |
| | | 205 | | private void RequireUnlocked() |
| | 4 | 206 | | { |
| | 4 | 207 | | if (!_session.IsUnlocked) |
| | 2 | 208 | | throw new InvalidOperationException("Sitzung ist gesperrt. Bitte zuerst entsperren."); |
| | 2 | 209 | | } |
| | | 210 | | |
| | | 211 | | private VaultHeader RequireCurrentHeader() |
| | 58 | 212 | | { |
| | 58 | 213 | | var header = _masterKeyRepo.Get() |
| | 58 | 214 | | ?? throw new InvalidOperationException("VaultHeader konnte nicht geladen werden."); |
| | | 215 | | |
| | 58 | 216 | | if (header.FormatVersion != VaultHeaderFormatVersion.Current) |
| | 0 | 217 | | throw new InvalidOperationException("Vault ist noch nicht auf das aktuelle Secret-Format migriert."); |
| | | 218 | | |
| | 58 | 219 | | return header; |
| | 58 | 220 | | } |
| | | 221 | | |
| | | 222 | | private CredentialRecord MaterializeRecord(CredentialRecord persisted, byte[] key, int vaultFormatVersion) |
| | 23 | 223 | | { |
| | 23 | 224 | | ValidatePersistedCurrentRecord(persisted); |
| | | 225 | | |
| | 22 | 226 | | var materialized = _credentialEnvelope.DecryptMetadata(persisted, key, vaultFormatVersion); |
| | 21 | 227 | | materialized.EncryptedPassword = persisted.EncryptedPassword.ToArray(); |
| | 21 | 228 | | materialized.EncryptedMetadata = persisted.EncryptedMetadata.ToArray(); |
| | 21 | 229 | | materialized.SecretFormatVersion = persisted.SecretFormatVersion; |
| | 21 | 230 | | materialized.MetadataFormatVersion = persisted.MetadataFormatVersion; |
| | 21 | 231 | | materialized.CredentialUuid = persisted.CredentialUuid; |
| | 21 | 232 | | materialized.CreatedAt = persisted.CreatedAt; |
| | 21 | 233 | | materialized.UpdatedAt = persisted.UpdatedAt; |
| | 21 | 234 | | materialized.Id = persisted.Id; |
| | 21 | 235 | | return materialized; |
| | 21 | 236 | | } |
| | | 237 | | |
| | 27 | 238 | | private static CredentialRecord ToPersistedRecord(CredentialRecord materialized) => new() |
| | 27 | 239 | | { |
| | 27 | 240 | | Id = materialized.Id, |
| | 27 | 241 | | Title = string.Empty, |
| | 27 | 242 | | Username = null, |
| | 27 | 243 | | EncryptedPassword = materialized.EncryptedPassword, |
| | 27 | 244 | | EncryptedMetadata = materialized.EncryptedMetadata, |
| | 27 | 245 | | CredentialUuid = materialized.CredentialUuid, |
| | 27 | 246 | | SecretFormatVersion = materialized.SecretFormatVersion, |
| | 27 | 247 | | MetadataFormatVersion = materialized.MetadataFormatVersion, |
| | 27 | 248 | | Url = null, |
| | 27 | 249 | | Notes = null, |
| | 27 | 250 | | CreatedAt = materialized.CreatedAt, |
| | 27 | 251 | | UpdatedAt = materialized.UpdatedAt, |
| | 27 | 252 | | IconKey = null, |
| | 27 | 253 | | CredentialType = CredentialType.Password, |
| | 27 | 254 | | }; |
| | | 255 | | |
| | | 256 | | private static string EnsureCredentialUuid(string credentialUuid) |
| | 4 | 257 | | => Guid.TryParseExact(credentialUuid, "N", out _) ? credentialUuid : Guid.NewGuid().ToString("N"); |
| | | 258 | | |
| | | 259 | | private static void ValidatePersistedCurrentRecord(CredentialRecord persisted) |
| | 23 | 260 | | { |
| | 23 | 261 | | if (persisted.MetadataFormatVersion != CredentialMetadataFormatVersion.Current) |
| | 0 | 262 | | return; |
| | | 263 | | |
| | 23 | 264 | | if (!Guid.TryParseExact(persisted.CredentialUuid, "N", out _)) |
| | 0 | 265 | | throw new InvalidOperationException("Aktuelle Records enthaelten eine ungueltige CredentialUuid."); |
| | | 266 | | |
| | 23 | 267 | | if (persisted.EncryptedMetadata.Length == 0) |
| | 0 | 268 | | throw new InvalidOperationException("Aktuelle Records enthaelten keine verschluesselten Metadaten."); |
| | | 269 | | |
| | 23 | 270 | | if (!string.IsNullOrEmpty(persisted.Title) || |
| | 23 | 271 | | !string.IsNullOrEmpty(persisted.Username) || |
| | 23 | 272 | | !string.IsNullOrEmpty(persisted.Url) || |
| | 23 | 273 | | !string.IsNullOrEmpty(persisted.Notes) || |
| | 23 | 274 | | !string.IsNullOrEmpty(persisted.IconKey) || |
| | 23 | 275 | | persisted.CredentialType != CredentialType.Password) |
| | 1 | 276 | | { |
| | 1 | 277 | | throw new InvalidOperationException("Aktuelle Records enthalten unerwartete Klartext-Metadaten."); |
| | | 278 | | } |
| | 22 | 279 | | } |
| | | 280 | | } |