середа, 2 березня 2016 р.

AWS Key Management System (KMS)


AWS KMS
  Key Management System - один з веб сервісів Amazon покликаний для захисту інформації, а саме енкриптання (шифрування) та декриптання (розшифрування) даних за допомогою ID ключа, що зберігається на AWS.
  Використання KMS є дуже рентабельним в тому плані коли вам не хочеться ламати голову над тим як шифрувати дані, як зберігати метадані шифрування (ключі, паролі чи фрази) і як захистити свій алгоритм від декомпіляції або іншого роду взломів. KMS все це інкапсулює в собі, все чим ви оперуєте це внутрішня ID ключа котрий зберігається в KMS і AWS креденшили (AccessKey ID & SecretKey) а останні означають що комусь доведеться в прямому сенсі слова "взламати" Amazon щоб добратися до ваших даних. Іншими словами використовуючи KMS ви перекладаєте обов'язок шифрування та розшифрування даних на Amazon.

Постановка задачі
  В цій статті ми повинні: 
  - Розібратися з AwsKms клієнтом, що входить до пакету AWS SDK;
  - Заміряти його перформенс і створити най оптимальнішу конфігурацію для шифрування і дешифрування 1000 об'єктів типу String, скажемо 1000 паролів користувачів, за один підхід;
  - Розібратися з проблемами збереження шифрованих даних в String форматі.
  
Паралельно / Послідовно
  AWS SDK містить дві реалізації KMS клієнта , послідовну (AWSKMSClient) і паралельну (AWSKMSAsyncClient), відрізняються вони конструкторами, паралельна версія підтримує конструктор для отримання ExecutorService (якщо його не передавати то по замовчуванню його розмір буде 50 потоків) а також паралельна версія клієнта має методи з приставкою Async, наприклад : "decryptAsync" для роботи у розпаралеленому режимі. Інші ж методи у них ідентичні (тобто обидва мають методи для роботи у послідовному режимі).
В цій статті буде використовуватись клієнт з підтримкою паралельних операцій.

Підготовка проекту
  Для того щоб почати працювати з KMS треба підключити AWS SDK до проекту , я використовую наступно залежність у моєму Apache Maven проекті :

<dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-java-sdk</artifactId>
      <version>1.10.51</version>
</dependency>

Створення клієнта
  Перш ніж створити клієнт і почати роботу потрібно ініціалізувати AWS креденшили, які клієнт приймає в конструкторі. Існує кілька способів отримати ці креденшили, можна імплементувати провайдер, і вже в середині вирішувати звідки отримати дані або ж просто ініціалізувати їх в коді, що ми і зробимо :

AWSCredentials awsCredentials = new AWSCredentials() {
    public String getAWSAccessKeyId() {
        return AWS_ACCESS_KEY_ID;
    }

    public String getAWSSecretKey() {
        return AWS_SECRET_KEY;
    }
};

  Далі потрібно створити конфігурацію для нашого клієнта, звісно можна обійтися без неї (в середині і так використається конфігурація по замовчуванню). Нам вона потрібена лише щоб переконфігурувати політику ретраїв (повторних спроб), оскільки KMS має певні рестрікшени (100 реквестів в секунду на запити  encrypt/decrypt та ін. одночасно) а ми використовуємо асинхронний клієнт, тому нам потрібно обробляти випадки коли виходимо за цей ліміт а також інші випадки при яких в асинхронному режимі варто повторити операцію (спершу я обробляв всі еррори і робив операції повторно в хендлері реквеста без цієї конфігурації, проте це не вірно, є випадки, наприклад: "дешифрування не валідних даних", які повторювати не треба). Сам клієнт вже має можливість повторювати певного роду помилки при роботі але по замовчуванню це вимкнено.
  Отже створюємо конфігурацію і задаємо кількість повторів (далі ми будемо визначати оптимальну кількість потоків в екзекютері нашого клієнта , адже чим більше потоків і даних тим більше треба буде повторів і тим гірший буде перформенс) :

ClientConfiguration clientConfiguration = PredefinedClientConfigurations.defaultConfig();
clientConfiguration.setMaxErrorRetry(1); 

  Наразі встановимо кількість повторів рівною 1.
  Маючи все що описано вище можна ініціалізувати KMS клієнт :

AWSKMSAsyncClient awsKmsClient = new AWSKMSAsyncClient(awsCredentials, clientConfiguration, 
Executors.newFixedThreadPool(50));

  В створеному клієнті ми використали наші AWS креденшили та конфігурацію, а також передали ExecutorService. Розмір пулу 50 є розміром по замовчуванню.

Шлях до AWS
  Amazon має багато дата-центрів по всій планеті і всі вони мають різні зони доступу (availability zone) тому і шлях потібно вказувати додатковим параметром :

awsKmsClient.setEndpoint(AWS_ENDPOINT);

  Для прикладу, шаблон шляху є таким : "https://kms.<availability_zone>.amazonaws.com", де <availability_zone> може бути: "eu-central-1".

Створення ключа для шифрування
  Для шифрування даних нам потрібно використовувати Encryption Key Id (Id ключа шифрування). Щоб створити цей ключ і дізнатися його ID можна скористатися нашим KMS клієнтом або через Веб консоль AWS. Я користувався AWS веб консоллю і для того, щоб це зробити потрібно піти по наступному шляху :


Маючи все попереднє можна сміло приступати до шифрування / розшифрування даних.

Шифруємо (Data Encryption)
  Для шифрування даних потрібно створити EnryptRequest і передати його на виконання нашому AWSKMSAsyncClient'у :

EncryptRequest encryptRequest = new EncryptRequest();
encryptRequest.setKeyId(KMS_KEY_ID);
encryptRequest.setPlaintext(ByteBuffer.wrap("This is text message".getBytes()));

  В методі "setKeyId" використовуємо ID ключа а в метод "setPlaintext" передаємо байт-буфер даних котрі шифруються, в мому випадку це проста текстова стрічка. Також АРІ об'єкта EncryptRequest дозволяє створити його за допомогою білдера :

EncryptRequest encryptRequest = new EncryptRequest().withKeyId(KMS_KEY_ID).withPlaintext(ByteBuffer.wrap("This is text message".getBytes()));

  Після цього передаємо запит шифрування на виконання нашому клієнту :

byte[] encryptedData = null;
awsKmsClient.encryptAsync(encryptRequest,
  new AsyncHandler<EncryptRequest, EncryptResult>() {
    public void onError(Exception e) {
      //error
    }

    public void onSuccess(EncryptRequest request, EncryptResult encryptResult) {
      // get encrypted data bytes from ByteBuffer
      encryptedData = encryptResult.getCiphertextBlob().array();
    }
  });

  Оскільки я використовую клієнт з паралельною обробкою мені потрібно обробити результат виконання обробником або ж зберегти отриманий після виконання методу "encryptAsync" об'єкт Future<EncryptResult> і дочекавшись його значення отримати бажаний результат. Як показано вище, я використав обробник і в методі "onSuccess" вичитую ByteBuffer а вже з нього беру байти закодованого повідомлення. Метод "onFailure" я тут не чіпаю, хоча з даним підходом (коли ми використали Retry Policy клієнта) можна сказати що якщо цей метод викличеться то можна сміло фейлити операцію і логувати екзепшен (раніше я пробував тут повторно виконувати шифрування, та це не є правильним адже спрацювати він може через те, наприклад, що ключа яким шифруєте нема).

Розшифровуємо (Data Decryption)
  Для того щоб розшифрувати наше повідомлення також потрібно створити запит :

DecryptRequest decryptRequest = new DecryptRequest().withCiphertextBlob(ByteBuffer.wrap(encryptedData));

Як бачите, для розшифрування повідомлення не потрібно вказувати ID ключа. А все тому, що зашифровані дані містять в собі мета-дані за допомогою яких відбувається розшифрування.

Запускаємо цей запит на виконання:

String result = null;
awsKmsClient.decryptAsync(decryptRequest,
  new AsyncHandler<DecryptRequest, DecryptResult>() {
    public void onError(Exception e) {
      //error
    }

    public void onSuccess(DecryptRequest request, DecryptResult decryptResult) {
      byte[] data = decryptResult.getPlaintext().array();
      result = new String(data);
    }
  });

  Аналогічно шифруванню, тут я використовує обробник. Як тільки виконається метод "onSuccess" я дістаю байти повідомлення і перетворюю в стрінгу.

  Усе вище працює без проблем. Загалом схему шифрування та дешифрування можна зобрадити так :




Проблеми зі збереженням шифрованих даних у форматі String
  Моя задача передбачала що дані (шифровані чи ні) передаються і зберігаються у форматі String. А String має конструктор що приймає масив байтів, тому паємо щойно зашифровані дані в стрінгу і зберігаємо (чи передаємо). Проте як тільки ми спробуємо перетворити стрінгу назад в байти і розшифрувати, ми отримаємо екзепшен з повідомленням :

Service: AWSKMS; Status Code: 400; Error Code: InvalidCiphertextException; Request ID: <your_request_id>

  А все тому, що байти отримані з об'єкта String є "поломаними" і AWS KMS не може їх провалідувати і розшифрувати. 
  Моє розуміння проблеми наступне: - Коли зашифрований масив байтів (отриманий з відповіді KMS клієнта) приводиться до об'єкту String (за допомогою його конструктора) тоді і відбувається кораптання даних. Всім відомо що String має кодування по замовчуванню рівне "UTF-8" а це від 4 до 6 байтів на символ, реально ж використовуються символи тільки до 2 в 21 сепені (знайшов на вікіпедії), а як веде себе String коли зустрічає код символу що перевищує це число ? Хоча може в цей момент шифровані дані ще не "поломані", проте коли дану String'у привести назад в масив байтів щоб розшифрувати - точно "поломані".
  Нажаль я глибоко в цю проблему не закопувався, тому якщо хтось розуміється краще, я буду дуже вдячний за коментарі. 
  
Що робити ?
  На просторах інтернету був знайдений солюшен - перетворити шифровані дані в формат Base64 :

byte[] encoded = Base64.encodeBase64(data);

  Для цього було використано утиліту з пакету Apache Commons.
  Отриманий після цього масив байтів можна спокійно приводити до об'єкту String і зберігати, надсилати чи що кому треба. Насправді навіть порівнюючи (при виводі) стрінгу створену з масиву байт шифрованих KMS'ом і стрінгу створену з тих же даних тільки перетворених у Base64 було чітко видно що стрінга першого варіанту взагалі була не "валідною" - містила пусті символи (підозрюю просто не відображалися) та якісь ієрогліфи, а друга стрінга була просто хаотичним набором латинських літер та символів, що зарактерно для відображення будь-якої захифрованої інформації.

Звісно треба не забути що в такому випадку для розшиврування даних потрібно їх привезти з Base64 у нормальний вигляд :

byte[] decoded = Base64.decodeBase64(data);

І вже цей набір байтів розшифровувати за допомогою KMS.
  Звісно це лише мій випадок, коли я зберагаю у форматі String, якщо у вас є можливість зберігати це як маисв байтів або blob об'єкт то цього не потрібно.

Тестування в одному потоці та пошук оптимальних значень повторів та розміру пула потоків для клієнта
  Для визначення найоптимальніших налаштуванн KMS клієнта поставимо наступні умови :
- Зашифрувати 1000 повідомлень;
- Розшифрувати 1000 повідомлень.

Отже запускаємо наш тестовий стенд (посилання на вихідний код буде в кінці) з розміром пула потоків 50 та кількісттю повторних спроб 2 :

- 978 повідомлень успішно зашифровані;
- 22 повідомлення не було зашифровано успішно;
- Аплікація не дойшла до розшифрування так як не всі повідомлення зашифрувалися;
- Ось таке повідомлення в консолі :

You have exceeded the rate at which you may call KMS. Reduce the frequency of your calls. (Service: AWSKMS; Status Code: 400; Error Code: ThrottlingException; Request ID: <your_request_id>


Що сталося?
  AWS KMS має ліміти на з'єднання, для encrypt i decrypt операції це 100 запитів в секунду.
Оскільки наш пул потоків в середині клієнта = 50, а дані додаються в пуп без затримки то ми попросту досягли ліміту по з'єднанях, причому такого роду помилка обробляються в середині клієнта і запит буде повторено так як кількість повторів = 2.
Запити були повторені та всеодно 22 повідомлення так і не зашифрувалися , одже 22 реквеста були не вдалі а значить і наша конфігурація теж. (Доречі, 50 це число пулу в клієнті що встановлюється по замовчуванню якщо його не задавати при створенні)

Міняємо налаштування, мабуть всі скажуть що замало повторних спроб, ОК , ставимо більше :

Запускаємо наш тестовий стенд з розміром пула потоків 50 та кількісттю повторних спроб 3 :

- 1000 повідомлень успішно зашифровані;
- 983 повідомлення успішно розшифровано;
- 27 повідомлень не було розшифровано успішно;
- Те саме повідомлення в консолі.

Збільшуємо кількість повторів знову? - Вважаю це хибним, адже повторна спроба це і теоретично погано і практично б'є по швидкодії.

Пропуную зменшити розмір пула потоків і повернути кількість спроб назад:

Запускаємо наш тестовий стенд з розміром пула потоків 20 та кількісттю повторних спроб 2 :

- 999 повідомлень успішно зашифровані;
- 1 повідомлення не було успішно зашифровано;

Зменшуємо далі :

Запускаємо наш тестовий стенд з розміром пула потоків 10 та кількісттю повторних спроб 2 :

- 1000 повідомлень успішно зашифровані;
- 1000 повідомлень успішно розшифровано.

Юххууу... отже конфігурацію знайшли, проте коли я запускав ту ж роботу тільки від кількох потоків (а ми знаємо що пул клієнта буде 1 для всіх) то проблеми виникали але дуже рідко , що змусило підняти кількість спроб до 3, далі усе було ідеально.

Чи впливає маленьких розмір пула на час виконання всіх операції загалом в порівнянні з більшим розміром? - так алетести показали що ця різниця = 10 мілісекунд і не є суттєвою в мому випадку. Для економію часу не буду наводити дані тесту швидкодії проте найоптимальніша на мій погляд конфігурація дає такий результат :

Кількість потоків - 1, Розмір пулу в клієнта - 10, кількість повторів - 3 :

Encryption time for 1000 items (MILLISECONDS) : 8515
Errors happened on encryption cycle : 0
Decryption time for 1000 items (MILLISECONDS) : 10828
Errors happened on decryption cycle : 0
GENERAL TIME TO ENCRYPT 1000 AND DECRYPT 1000 items (MILLISECONDS) : 19343

20 секунд на все про все , думаю хороший результат =) .

Ціни
  Відомо що Amazon заробляє на всіх своїх сервісах. Нижче наведена цінова політика на момент написання статті:

- Перші 20 000 реквестів у місяць безкоштовно (Encrypt / Decrypt / Create Key / Delete Key);
- Настіпні 10 000 реквестів після використаних безкоштовних будуть коштувати 0.3 центи;
- За зберігання ключа стягуватиметься 1 долар в місяць

Сорси проекту тут

Дякую усім за увагу!


Немає коментарів :

Дописати коментар