| | | 1 | | using LOCKnet.Core.DataAbstractions; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | |
| | | 5 | | namespace LOCKnet.Core.Crypto; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Versionierter Envelope fuer Credential-Secrets. |
| | | 9 | | /// V1 speichert <c>[Version][Nonce][Tag][Ciphertext]</c> und bindet den Ciphertext per AAD an stabile Metadaten. |
| | | 10 | | /// </summary> |
| | | 11 | | public sealed class CredentialEnvelopeService : ICredentialEnvelopeService |
| | | 12 | | { |
| | | 13 | | private const int EnvelopeHeaderBytes = 1; |
| | | 14 | | private const byte SecretFieldDiscriminator = 1; |
| | | 15 | | private const byte MetadataFieldDiscriminator = 2; |
| | | 16 | | private readonly IEncryptionService _encryption; |
| | | 17 | | |
| | | 18 | | /// <summary> |
| | | 19 | | /// Initialisiert eine neue Instanz von <see cref="CredentialEnvelopeService"/>. |
| | | 20 | | /// </summary> |
| | 105 | 21 | | public CredentialEnvelopeService(IEncryptionService encryption) |
| | 105 | 22 | | { |
| | 105 | 23 | | ArgumentNullException.ThrowIfNull(encryption); |
| | 105 | 24 | | _encryption = encryption; |
| | 105 | 25 | | } |
| | | 26 | | |
| | | 27 | | /// <inheritdoc/> |
| | 57 | 28 | | public int CurrentVersion => CredentialSecretFormatVersion.Current; |
| | | 29 | | |
| | | 30 | | /// <inheritdoc/> |
| | 69 | 31 | | public int CurrentMetadataVersion => CredentialMetadataFormatVersion.Current; |
| | | 32 | | |
| | | 33 | | /// <inheritdoc/> |
| | | 34 | | public byte[] Encrypt(byte[] plaintext, byte[] key, CredentialRecord credential, int vaultFormatVersion) |
| | 34 | 35 | | { |
| | 34 | 36 | | ArgumentNullException.ThrowIfNull(plaintext); |
| | 34 | 37 | | ArgumentNullException.ThrowIfNull(key); |
| | 34 | 38 | | ArgumentNullException.ThrowIfNull(credential); |
| | 34 | 39 | | ValidateCredentialContext(credential); |
| | | 40 | | |
| | 33 | 41 | | var aad = BuildAssociatedDataV2(vaultFormatVersion, credential.CredentialUuid, SecretFieldDiscriminator); |
| | 33 | 42 | | var packet = _encryption.Encrypt(plaintext, key, aad); |
| | 33 | 43 | | var envelope = new byte[EnvelopeHeaderBytes + packet.Length]; |
| | 33 | 44 | | envelope[0] = (byte)CurrentVersion; |
| | 33 | 45 | | packet.CopyTo(envelope, EnvelopeHeaderBytes); |
| | 33 | 46 | | return envelope; |
| | 33 | 47 | | } |
| | | 48 | | |
| | | 49 | | /// <inheritdoc/> |
| | | 50 | | public byte[] Decrypt(CredentialRecord credential, byte[] key, int vaultFormatVersion) |
| | 24 | 51 | | { |
| | 24 | 52 | | ArgumentNullException.ThrowIfNull(credential); |
| | 24 | 53 | | ArgumentNullException.ThrowIfNull(key); |
| | | 54 | | |
| | 24 | 55 | | return credential.SecretFormatVersion switch |
| | 24 | 56 | | { |
| | 1 | 57 | | CredentialSecretFormatVersion.Legacy => _encryption.Decrypt(credential.EncryptedPassword, key), |
| | 4 | 58 | | CredentialSecretFormatVersion.AesGcmV1 => DecryptV1(credential, key, vaultFormatVersion), |
| | 18 | 59 | | CredentialSecretFormatVersion.AesGcmV2 => DecryptV2(credential, key, vaultFormatVersion), |
| | 1 | 60 | | _ => throw new InvalidOperationException($"Nicht unterstuetzte Secret-Formatversion: {credential.SecretFormatVersi |
| | 24 | 61 | | }; |
| | 16 | 62 | | } |
| | | 63 | | |
| | | 64 | | /// <inheritdoc/> |
| | | 65 | | public byte[] EncryptMetadata(CredentialRecord credential, byte[] key, int vaultFormatVersion) |
| | 36 | 66 | | { |
| | 36 | 67 | | ArgumentNullException.ThrowIfNull(credential); |
| | 36 | 68 | | ArgumentNullException.ThrowIfNull(key); |
| | 36 | 69 | | ValidateCredentialContext(credential); |
| | | 70 | | |
| | 36 | 71 | | var metadataBytes = JsonSerializer.SerializeToUtf8Bytes(new CredentialMetadataPayload |
| | 36 | 72 | | { |
| | 36 | 73 | | Title = credential.Title, |
| | 36 | 74 | | Username = credential.Username, |
| | 36 | 75 | | Url = credential.Url, |
| | 36 | 76 | | Notes = credential.Notes, |
| | 36 | 77 | | IconKey = credential.IconKey, |
| | 36 | 78 | | CredentialType = credential.CredentialType, |
| | 36 | 79 | | }); |
| | | 80 | | |
| | | 81 | | try |
| | 36 | 82 | | { |
| | 36 | 83 | | var aad = BuildAssociatedDataV2(vaultFormatVersion, credential.CredentialUuid, MetadataFieldDiscriminator); |
| | 36 | 84 | | var packet = _encryption.Encrypt(metadataBytes, key, aad); |
| | 36 | 85 | | var envelope = new byte[EnvelopeHeaderBytes + packet.Length]; |
| | 36 | 86 | | envelope[0] = (byte)CurrentMetadataVersion; |
| | 36 | 87 | | packet.CopyTo(envelope, EnvelopeHeaderBytes); |
| | 36 | 88 | | return envelope; |
| | | 89 | | } |
| | | 90 | | finally |
| | 36 | 91 | | { |
| | 36 | 92 | | CryptographicOperations.ZeroMemory(metadataBytes); |
| | 36 | 93 | | } |
| | 36 | 94 | | } |
| | | 95 | | |
| | | 96 | | /// <inheritdoc/> |
| | | 97 | | public CredentialRecord DecryptMetadata(CredentialRecord credential, byte[] key, int vaultFormatVersion) |
| | 38 | 98 | | { |
| | 38 | 99 | | ArgumentNullException.ThrowIfNull(credential); |
| | 38 | 100 | | ArgumentNullException.ThrowIfNull(key); |
| | | 101 | | |
| | 38 | 102 | | return credential.MetadataFormatVersion switch |
| | 38 | 103 | | { |
| | 1 | 104 | | CredentialMetadataFormatVersion.Legacy => CloneRecord(credential), |
| | 36 | 105 | | CredentialMetadataFormatVersion.AesGcmV1 => DecryptMetadataV1(credential, key, vaultFormatVersion), |
| | 1 | 106 | | _ => throw new InvalidOperationException($"Nicht unterstuetzte Metadaten-Formatversion: {credential.MetadataFormat |
| | 38 | 107 | | }; |
| | 33 | 108 | | } |
| | | 109 | | |
| | | 110 | | private byte[] DecryptV1(CredentialRecord credential, byte[] key, int vaultFormatVersion) |
| | 4 | 111 | | { |
| | 4 | 112 | | ValidateCredentialContext(credential); |
| | 4 | 113 | | if (credential.EncryptedPassword.Length <= EnvelopeHeaderBytes) |
| | 1 | 114 | | throw new InvalidOperationException("Credential-Envelope ist zu kurz."); |
| | | 115 | | |
| | 3 | 116 | | var versionByte = credential.EncryptedPassword[0]; |
| | 3 | 117 | | if (versionByte != CredentialSecretFormatVersion.AesGcmV1) |
| | 1 | 118 | | throw new InvalidOperationException($"Envelope-Version {versionByte} passt nicht zum gespeicherten Secret-Format." |
| | | 119 | | |
| | 2 | 120 | | var packet = credential.EncryptedPassword[EnvelopeHeaderBytes..]; |
| | 2 | 121 | | var aad = BuildAssociatedDataV1(vaultFormatVersion, credential.CredentialUuid, credential.CredentialType, SecretFiel |
| | 1 | 122 | | return _encryption.Decrypt(packet, key, aad); |
| | 1 | 123 | | } |
| | | 124 | | |
| | | 125 | | private byte[] DecryptV2(CredentialRecord credential, byte[] key, int vaultFormatVersion) |
| | 18 | 126 | | { |
| | 18 | 127 | | ValidateCredentialContext(credential); |
| | 17 | 128 | | if (credential.EncryptedPassword.Length <= EnvelopeHeaderBytes) |
| | 1 | 129 | | throw new InvalidOperationException("Credential-Envelope ist zu kurz."); |
| | | 130 | | |
| | 16 | 131 | | var versionByte = credential.EncryptedPassword[0]; |
| | 16 | 132 | | if (versionByte != CredentialSecretFormatVersion.AesGcmV2) |
| | 1 | 133 | | throw new InvalidOperationException($"Envelope-Version {versionByte} passt nicht zum gespeicherten Secret-Format." |
| | | 134 | | |
| | 15 | 135 | | var packet = credential.EncryptedPassword[EnvelopeHeaderBytes..]; |
| | 15 | 136 | | var aad = BuildAssociatedDataV2(vaultFormatVersion, credential.CredentialUuid, SecretFieldDiscriminator); |
| | 15 | 137 | | return _encryption.Decrypt(packet, key, aad); |
| | 14 | 138 | | } |
| | | 139 | | |
| | | 140 | | private CredentialRecord DecryptMetadataV1(CredentialRecord credential, byte[] key, int vaultFormatVersion) |
| | 36 | 141 | | { |
| | 36 | 142 | | ValidateCredentialContext(credential); |
| | 36 | 143 | | if (credential.EncryptedMetadata.Length <= EnvelopeHeaderBytes) |
| | 2 | 144 | | throw new InvalidOperationException("Metadaten-Envelope ist zu kurz."); |
| | | 145 | | |
| | 34 | 146 | | var versionByte = credential.EncryptedMetadata[0]; |
| | 34 | 147 | | if (versionByte != CredentialMetadataFormatVersion.AesGcmV1) |
| | 1 | 148 | | throw new InvalidOperationException($"Envelope-Version {versionByte} passt nicht zum gespeicherten Metadaten-Forma |
| | | 149 | | |
| | 33 | 150 | | var packet = credential.EncryptedMetadata[EnvelopeHeaderBytes..]; |
| | 33 | 151 | | var aad = BuildAssociatedDataV2(vaultFormatVersion, credential.CredentialUuid, MetadataFieldDiscriminator); |
| | 33 | 152 | | var plaintext = _encryption.Decrypt(packet, key, aad); |
| | | 153 | | try |
| | 33 | 154 | | { |
| | 33 | 155 | | var payload = JsonSerializer.Deserialize<CredentialMetadataPayload>(plaintext) |
| | 33 | 156 | | ?? throw new InvalidOperationException("Metadaten konnten nicht deserialisiert werden."); |
| | | 157 | | |
| | 32 | 158 | | var clone = CloneRecord(credential); |
| | 32 | 159 | | clone.Title = payload.Title; |
| | 32 | 160 | | clone.Username = payload.Username; |
| | 32 | 161 | | clone.Url = payload.Url; |
| | 32 | 162 | | clone.Notes = payload.Notes; |
| | 32 | 163 | | clone.IconKey = payload.IconKey; |
| | 32 | 164 | | clone.CredentialType = payload.CredentialType; |
| | 32 | 165 | | return clone; |
| | | 166 | | } |
| | | 167 | | finally |
| | 33 | 168 | | { |
| | 33 | 169 | | CryptographicOperations.ZeroMemory(plaintext); |
| | 33 | 170 | | } |
| | 32 | 171 | | } |
| | | 172 | | |
| | | 173 | | private static byte[] BuildAssociatedDataV1(int vaultFormatVersion, string credentialUuid, CredentialType credentialTy |
| | 2 | 174 | | { |
| | 2 | 175 | | if (vaultFormatVersion != VaultHeaderFormatVersion.Current) |
| | 1 | 176 | | throw new InvalidOperationException($"Envelope erwartet VaultHeader-Format {VaultHeaderFormatVersion.Current}, erh |
| | | 177 | | |
| | 1 | 178 | | var aad = new byte[22]; |
| | 1 | 179 | | BitConverter.GetBytes(vaultFormatVersion).CopyTo(aad, 0); |
| | 1 | 180 | | Guid.ParseExact(credentialUuid, "N").TryWriteBytes(aad.AsSpan(4, 16)); |
| | 1 | 181 | | aad[20] = fieldDiscriminator; |
| | 1 | 182 | | aad[21] = (byte)credentialType; |
| | 1 | 183 | | return aad; |
| | 1 | 184 | | } |
| | | 185 | | |
| | | 186 | | private static byte[] BuildAssociatedDataV2(int vaultFormatVersion, string credentialUuid, byte fieldDiscriminator) |
| | 117 | 187 | | { |
| | 117 | 188 | | if (vaultFormatVersion != VaultHeaderFormatVersion.Current) |
| | 0 | 189 | | throw new InvalidOperationException($"Envelope erwartet VaultHeader-Format {VaultHeaderFormatVersion.Current}, erh |
| | | 190 | | |
| | 117 | 191 | | var aad = new byte[21]; |
| | 117 | 192 | | BitConverter.GetBytes(vaultFormatVersion).CopyTo(aad, 0); |
| | 117 | 193 | | Guid.ParseExact(credentialUuid, "N").TryWriteBytes(aad.AsSpan(4, 16)); |
| | 117 | 194 | | aad[20] = fieldDiscriminator; |
| | 117 | 195 | | return aad; |
| | 117 | 196 | | } |
| | | 197 | | |
| | | 198 | | private static void ValidateCredentialContext(CredentialRecord credential) |
| | 128 | 199 | | { |
| | 128 | 200 | | if (!Guid.TryParseExact(credential.CredentialUuid, "N", out _)) |
| | 2 | 201 | | throw new InvalidOperationException("CredentialUuid muss eine GUID im N-Format sein."); |
| | 126 | 202 | | } |
| | | 203 | | |
| | 33 | 204 | | private static CredentialRecord CloneRecord(CredentialRecord credential) => new() |
| | 33 | 205 | | { |
| | 33 | 206 | | Id = credential.Id, |
| | 33 | 207 | | Title = credential.Title, |
| | 33 | 208 | | Username = credential.Username, |
| | 33 | 209 | | EncryptedPassword = credential.EncryptedPassword.ToArray(), |
| | 33 | 210 | | EncryptedMetadata = credential.EncryptedMetadata.ToArray(), |
| | 33 | 211 | | CredentialUuid = credential.CredentialUuid, |
| | 33 | 212 | | SecretFormatVersion = credential.SecretFormatVersion, |
| | 33 | 213 | | MetadataFormatVersion = credential.MetadataFormatVersion, |
| | 33 | 214 | | Url = credential.Url, |
| | 33 | 215 | | Notes = credential.Notes, |
| | 33 | 216 | | CreatedAt = credential.CreatedAt, |
| | 33 | 217 | | UpdatedAt = credential.UpdatedAt, |
| | 33 | 218 | | IconKey = credential.IconKey, |
| | 33 | 219 | | CredentialType = credential.CredentialType, |
| | 33 | 220 | | }; |
| | | 221 | | |
| | | 222 | | private sealed class CredentialMetadataPayload |
| | | 223 | | { |
| | 204 | 224 | | public string Title { get; set; } = string.Empty; |
| | 136 | 225 | | public string? Username { get; set; } |
| | 136 | 226 | | public string? Url { get; set; } |
| | 136 | 227 | | public string? Notes { get; set; } |
| | 136 | 228 | | public string? IconKey { get; set; } |
| | 136 | 229 | | public CredentialType CredentialType { get; set; } |
| | | 230 | | } |
| | | 231 | | } |