keysloth 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module KeySloth
7
+ # Модуль для криптографических операций KeySloth
8
+ #
9
+ # Реализует шифрование и дешифрование файлов с использованием AES-256-GCM.
10
+ # Обеспечивает защиту целостности данных и аутентификацию.
11
+ # Использует PBKDF2 для генерации ключей из паролей.
12
+ #
13
+ # @example Использование
14
+ # crypto = KeySloth::Crypto.new('secret_password')
15
+ #
16
+ # # Шифрование файла
17
+ # encrypted = crypto.encrypt_file(file_content)
18
+ #
19
+ # # Дешифрование файла
20
+ # decrypted = crypto.decrypt_file(encrypted)
21
+ #
22
+ # @author KeySloth Team
23
+ # @since 0.1.0
24
+ class Crypto
25
+ # Алгоритм шифрования
26
+ CIPHER_ALGORITHM = 'aes-256-gcm'
27
+
28
+ # Длина ключа в байтах (256 бит)
29
+ KEY_LENGTH = 32
30
+
31
+ # Длина инициализационного вектора для GCM
32
+ IV_LENGTH = 12
33
+
34
+ # Длина соли для PBKDF2
35
+ SALT_LENGTH = 32
36
+
37
+ # Количество итераций для PBKDF2
38
+ PBKDF2_ITERATIONS = 100_000
39
+
40
+ # Длина authentication tag для GCM
41
+ AUTH_TAG_LENGTH = 16
42
+
43
+ # Инициализация криптографического модуля
44
+ #
45
+ # @param password [String] Пароль для шифрования/дешифрования
46
+ # @param logger [KeySloth::Logger] Логгер для вывода сообщений
47
+ # @raise [CryptoError] при некорректном пароле
48
+ def initialize(password, logger = nil)
49
+ @password = password&.to_s
50
+ @logger = logger || Logger.new(level: :error)
51
+
52
+ validate_password!
53
+ end
54
+
55
+ # Шифрует содержимое файла
56
+ #
57
+ # @param content [String] Содержимое файла для шифрования
58
+ # @return [String] Зашифрованный контент в формате Base64
59
+ # @raise [CryptoError] при ошибках шифрования
60
+ def encrypt_file(content)
61
+ @logger.debug('Начинаем шифрование файла')
62
+
63
+ begin
64
+ # Обрабатываем пустой контент
65
+ content_to_encrypt = content.to_s
66
+ # Добавляем пробел для пустого контента
67
+ content_to_encrypt = ' ' if content_to_encrypt.empty?
68
+
69
+ # Генерируем случайную соль и IV
70
+ salt = generate_random_bytes(SALT_LENGTH)
71
+ iv = generate_random_bytes(IV_LENGTH)
72
+
73
+ # Выводим ключ из пароля
74
+ key = derive_key(@password, salt)
75
+
76
+ # Создаем cipher для шифрования
77
+ cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
78
+ cipher.encrypt
79
+ cipher.key = key
80
+ cipher.iv = iv
81
+
82
+ # Шифруем данные
83
+ encrypted_data = cipher.update(content_to_encrypt) + cipher.final
84
+ auth_tag = cipher.auth_tag
85
+
86
+ # Упаковываем все компоненты в единый блок
87
+ packed_data = pack_encrypted_data(salt, iv, auth_tag, encrypted_data)
88
+
89
+ @logger.debug("Файл успешно зашифрован (размер: #{encrypted_data.length} байт)")
90
+ Base64.strict_encode64(packed_data)
91
+ rescue StandardError => e
92
+ @logger.error('Ошибка при шифровании файла', e)
93
+ raise CryptoError, "Не удалось зашифровать файл: #{e.message}"
94
+ end
95
+ end
96
+
97
+ # Дешифрует содержимое файла
98
+ #
99
+ # @param encrypted_content [String] Зашифрованный контент в формате Base64
100
+ # @return [String] Расшифрованное содержимое файла
101
+ # @raise [CryptoError] при ошибках дешифрования
102
+ def decrypt_file(encrypted_content)
103
+ @logger.debug('Начинаем дешифрование файла')
104
+
105
+ begin
106
+ # Декодируем из Base64
107
+ packed_data = Base64.strict_decode64(encrypted_content.to_s)
108
+
109
+ # Распаковываем компоненты
110
+ salt, iv, auth_tag, encrypted_data = unpack_encrypted_data(packed_data)
111
+
112
+ # Выводим ключ из пароля
113
+ key = derive_key(@password, salt)
114
+
115
+ # Создаем cipher для дешифрования
116
+ cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
117
+ cipher.decrypt
118
+ cipher.key = key
119
+ cipher.iv = iv
120
+ cipher.auth_tag = auth_tag
121
+
122
+ # Дешифруем данные
123
+ decrypted_data = cipher.update(encrypted_data) + cipher.final
124
+
125
+ @logger.debug("Файл успешно расшифрован (размер: #{decrypted_data.length} байт)")
126
+ decrypted_data
127
+ rescue OpenSSL::Cipher::CipherError => e
128
+ @logger.error('Ошибка дешифрования - возможно неверный пароль', e)
129
+ raise CryptoError, 'Неверный пароль или поврежденные данные'
130
+ rescue StandardError => e
131
+ @logger.error('Ошибка при дешифровании файла', e)
132
+ raise CryptoError, "Не удалось расшифровать файл: #{e.message}"
133
+ end
134
+ end
135
+
136
+ # Проверяет целостность зашифрованного файла
137
+ #
138
+ # @param encrypted_content [String] Зашифрованный контент для проверки
139
+ # @return [Boolean] true если файл не поврежден
140
+ def verify_integrity(encrypted_content)
141
+ @logger.debug('Проверяем целостность зашифрованного файла')
142
+
143
+ begin
144
+ # Проверяем что контент не пустой
145
+ return false if encrypted_content.nil? || encrypted_content.empty?
146
+
147
+ # Декодируем из Base64
148
+ packed_data = Base64.strict_decode64(encrypted_content.to_s)
149
+
150
+ # Проверяем минимальный размер данных
151
+ min_size = 4 + SALT_LENGTH + 4 + IV_LENGTH + 4 + AUTH_TAG_LENGTH + 1
152
+ return false if packed_data.length < min_size
153
+
154
+ # Распаковываем и проверяем структуру данных
155
+ salt, iv, auth_tag, encrypted_data = unpack_encrypted_data(packed_data)
156
+
157
+ # Проверяем что все компоненты присутствуют
158
+ return false if salt.nil? || iv.nil? || auth_tag.nil? || encrypted_data.nil?
159
+ return false if salt.length != SALT_LENGTH
160
+ return false if iv.length != IV_LENGTH
161
+ return false if auth_tag.length != AUTH_TAG_LENGTH
162
+ return false if encrypted_data.empty?
163
+
164
+ @logger.debug('Файл прошел проверку целостности')
165
+ true
166
+ rescue StandardError => e
167
+ @logger.debug("Ошибка проверки целостности: #{e.message}")
168
+ false
169
+ end
170
+ end
171
+
172
+ # Выполняет полную проверку целостности с проверкой расшифровки
173
+ #
174
+ # @param encrypted_content [String] Зашифрованный контент для проверки
175
+ # @return [Hash] Результат проверки с деталями
176
+ def verify_integrity_detailed(encrypted_content)
177
+ @logger.debug('Выполняем детальную проверку целостности')
178
+
179
+ result = {
180
+ valid: false,
181
+ structure_valid: false,
182
+ decryption_valid: false,
183
+ error: nil
184
+ }
185
+
186
+ begin
187
+ # Проверяем структуру
188
+ result[:structure_valid] = verify_integrity(encrypted_content)
189
+ return result unless result[:structure_valid]
190
+
191
+ # Проверяем возможность расшифровки (полная проверка)
192
+ begin
193
+ decrypt_file(encrypted_content)
194
+ result[:decryption_valid] = true
195
+ rescue CryptoError
196
+ result[:decryption_valid] = false
197
+ end
198
+
199
+ result[:valid] = result[:structure_valid] && result[:decryption_valid]
200
+ @logger.debug("Детальная проверка завершена: #{result}")
201
+ rescue OpenSSL::Cipher::CipherError => e
202
+ result[:error] = 'Неверный пароль или поврежденные данные'
203
+ @logger.debug("Ошибка расшифровки при проверке: #{e.message}")
204
+ rescue StandardError => e
205
+ result[:error] = e.message
206
+ @logger.debug("Ошибка детальной проверки: #{e.message}")
207
+ end
208
+
209
+ result
210
+ end
211
+
212
+ private
213
+
214
+ # Валидирует пароль
215
+ #
216
+ # @raise [CryptoError] при некорректном пароле
217
+ def validate_password!
218
+ raise CryptoError, 'Пароль не может быть пустым' if @password.nil? || @password.empty?
219
+
220
+ raise CryptoError, 'Пароль должен содержать минимум 8 символов' if @password.length < 8
221
+ end
222
+
223
+ # Генерирует случайные байты
224
+ #
225
+ # @param length [Integer] Количество байт
226
+ # @return [String] Случайные байты
227
+ def generate_random_bytes(length)
228
+ OpenSSL::Random.random_bytes(length)
229
+ end
230
+
231
+ # Выводит ключ из пароля с использованием PBKDF2
232
+ #
233
+ # @param password [String] Пароль
234
+ # @param salt [String] Соль
235
+ # @return [String] Выведенный ключ
236
+ def derive_key(password, salt)
237
+ OpenSSL::PKCS5.pbkdf2_hmac(
238
+ password,
239
+ salt,
240
+ PBKDF2_ITERATIONS,
241
+ KEY_LENGTH,
242
+ OpenSSL::Digest.new('SHA256')
243
+ )
244
+ end
245
+
246
+ # Упаковывает зашифрованные данные в единый блок
247
+ #
248
+ # @param salt [String] Соль
249
+ # @param iv [String] Инициализационный вектор
250
+ # @param auth_tag [String] Authentication tag
251
+ # @param encrypted_data [String] Зашифрованные данные
252
+ # @return [String] Упакованные данные
253
+ def pack_encrypted_data(salt, iv, auth_tag, encrypted_data)
254
+ # Формат: [длина_соли][соль][длина_iv][iv][длина_auth_tag][auth_tag][зашифрованные_данные]
255
+ [
256
+ SALT_LENGTH,
257
+ salt,
258
+ IV_LENGTH,
259
+ iv,
260
+ AUTH_TAG_LENGTH,
261
+ auth_tag,
262
+ encrypted_data
263
+ ].pack('Na*Na*Na*a*')
264
+ end
265
+
266
+ # Распаковывает зашифрованные данные
267
+ #
268
+ # @param packed_data [String] Упакованные данные
269
+ # @return [Array] Массив [соль, iv, auth_tag, зашифрованные_данные]
270
+ # @raise [CryptoError] при некорректном формате данных
271
+ def unpack_encrypted_data(packed_data)
272
+ offset = 0
273
+
274
+ # Извлекаем соль
275
+ salt_length = packed_data[offset, 4].unpack1('N')
276
+ offset += 4
277
+ raise CryptoError, 'Некорректная длина соли' unless salt_length == SALT_LENGTH
278
+
279
+ salt = packed_data[offset, salt_length]
280
+ offset += salt_length
281
+
282
+ # Извлекаем IV
283
+ iv_length = packed_data[offset, 4].unpack1('N')
284
+ offset += 4
285
+ raise CryptoError, 'Некорректная длина IV' unless iv_length == IV_LENGTH
286
+
287
+ iv = packed_data[offset, iv_length]
288
+ offset += iv_length
289
+
290
+ # Извлекаем auth_tag
291
+ auth_tag_length = packed_data[offset, 4].unpack1('N')
292
+ offset += 4
293
+ raise CryptoError, 'Некорректная длина auth_tag' unless auth_tag_length == AUTH_TAG_LENGTH
294
+
295
+ auth_tag = packed_data[offset, auth_tag_length]
296
+ offset += auth_tag_length
297
+
298
+ # Остальные данные - зашифрованный контент
299
+ encrypted_data = packed_data[offset..]
300
+
301
+ [salt, iv, auth_tag, encrypted_data]
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeySloth
4
+ # Базовый класс для всех ошибок KeySloth
5
+ class KeySlothError < StandardError
6
+ # Инициализация ошибки с сообщением и причиной
7
+ #
8
+ # @param message [String] Сообщение об ошибке
9
+ # @param cause [Exception, nil] Исходная причина ошибки (опционально)
10
+ def initialize(message = nil, cause = nil)
11
+ super(message)
12
+ @cause = cause
13
+ end
14
+
15
+ # Возвращает исходную причину ошибки
16
+ #
17
+ # @return [Exception, nil] Исходная причина ошибки или nil
18
+ attr_reader :cause
19
+ end
20
+
21
+ # Ошибки криптографических операций
22
+ class CryptoError < KeySlothError; end
23
+
24
+ # Ошибки работы с Git репозиторием
25
+ class RepositoryError < KeySlothError; end
26
+
27
+ # Ошибки файловой системы
28
+ class FileSystemError < KeySlothError; end
29
+
30
+ # Ошибки конфигурации
31
+ class ConfigurationError < KeySlothError; end
32
+
33
+ # Ошибки аутентификации
34
+ class AuthenticationError < KeySlothError; end
35
+
36
+ # Ошибки валидации входных данных
37
+ class ValidationError < KeySlothError; end
38
+
39
+ # Ошибки сетевого соединения
40
+ class NetworkError < KeySlothError; end
41
+ end