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.
- checksums.yaml +7 -0
- data/.keyslothrc.example +14 -0
- data/.rspec +4 -0
- data/.rubocop.yml +98 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +45 -0
- data/Makefile +193 -0
- data/README.md +448 -0
- data/Rakefile +99 -0
- data/bin/keysloth +16 -0
- data/keysloth.gemspec +51 -0
- data/lib/keysloth/cli.rb +468 -0
- data/lib/keysloth/config.rb +129 -0
- data/lib/keysloth/crypto.rb +304 -0
- data/lib/keysloth/errors.rb +41 -0
- data/lib/keysloth/file_manager.rb +450 -0
- data/lib/keysloth/git_manager.rb +394 -0
- data/lib/keysloth/logger.rb +172 -0
- data/lib/keysloth/version.rb +6 -0
- data/lib/keysloth.rb +275 -0
- data/task/cr.md +59 -0
- data/task/plan.md +169 -0
- data/task/ragged_removing.md +103 -0
- data/task/task.md +43 -0
- data/task/test_plan.md +266 -0
- metadata +174 -0
|
@@ -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
|