הצפנה מצד הלקוח באמצעות Tink ו-Cloud KMS

במאמר הזה מוסבר איך להצפין נתונים באופן מקומי ולהעלות אותם ל-Cloud Storage באמצעות Tink ו-Cloud Key Management Service‏ (Cloud KMS). ‫Tink היא ספריית קוד פתוח לקריפטוגרפיה שנכתבה על ידי מומחי קריפטוגרפיה ומהנדסי אבטחה ב-Google.

סקירה כללית

הצפנה מצד הלקוח היא כל הצפנה שמבוצעת לפני שליחת הנתונים לענן. כשמשתמשים בהצפנה מצד הלקוח, אתם אחראים ליצירה ולניהול של מפתחות ההצפנה ולהצפנה של הנתונים לפני ששולחים אותם לענן.

בנושא הזה נסביר איך להטמיע הצפנת מעטפות בצד הלקוח באמצעות Tink, עם מפתח הצפנה ב-Cloud KMS.

גרסה של המדריך הזה שמבוססת על Terraform זמינה במאגר GitHub של kms-solutions.

לפני שמתחילים

  1. יוצרים מפתח הצפנה סימטרי של Cloud KMS להצפנה. רושמים את ה-URI של המפתח. תצטרכו אותו בהמשך.
  2. מתקינים את Tink לשימוש עם Cloud KMS.
  3. יוצרים קטגוריה ב-Cloud Storage כדי להעלות את הנתונים המוצפנים.

התפקידים הנדרשים

כדי לוודא שלחשבון השירות שלכם יש את ההרשאות הנדרשות לשימוש במפתחות Cloud KMS עם Tink, צריך לבקש מהאדמין לתת לחשבון השירות את תפקיד ה-IAM Cloud KMS CryptoKey Encrypter/Decrypter (roles/cloudkms.cryptoKeyEncrypterDecrypter) במפתח.

מידע נוסף על הקצאת תפקידים מופיע במאמר ניהול הגישה לפרויקטים, לתיקיות ולארגונים.

יכול להיות שהאדמין יוכל גם לתת לחשבון השירות את ההרשאות שנדרשות באמצעות תפקידים בהתאמה אישית או תפקידים מוגדרים מראש אחרים.

הצפנת מעטפות באמצעות Tink

בהצפנת מעטפות, מפתח Cloud KMS משמש כמפתח להצפנת מפתחות הצפנה (KEK). כלומר, הוא משמש להצפנת מפתחות להצפנת נתונים (DEK), שמשמשים להצפנת הנתונים בפועל.

אחרי שיוצרים KEK ב-Cloud KMS, כדי להצפין כל הודעה צריך:

  1. יוצרים מפתח להצפנת נתונים (DEK) באופן מקומי.
  2. משתמשים במפתח ה-DEK באופן מקומי כדי להצפין את ההודעה.
  3. משתמשים ב-Cloud KMS כדי להצפין (לארוז) את ה-DEK באמצעות ה-KEK.
  4. אחסון הנתונים המוצפנים וה-DEK הארוז.

אם משתמשים ב-Tink, לא צריך להטמיע את תהליך ההצפנה הזה מאפס.

כדי להשתמש ב-Tink להצפנת מעטפות, צריך לספק ל-Tink URI של מפתח ופרטי כניסה. מזהה ה-URI של המפתח מצביע על ה-KEK ב-Cloud KMS, והפרטים של אמצעי הזיהוי מאפשרים ל-Tink להשתמש ב-KEK. ‫Tink יוצר את ה-DEK, מצפין את הנתונים, עוטף את ה-DEK ואז מחזיר טקסט מוצפן יחיד עם הנתונים המוצפנים וה-DEK העטוף.

‫Tink תומך בהצפנת מעטפה ב-Python, ב-Java, ב-C++‎ וב-Go באמצעות הפרימיטיב Authenticated Encryption with Associated Data (AEAD).

חיבור Tink ו-Cloud KMS

כדי להצפין את מפתחות ה-DEK שנוצרו על ידי Tink באמצעות מפתח ה-KEK שלכם ב-Cloud KMS, צריך לאחזר את מזהה ה-URI של מפתח ה-KEK. ב-Cloud KMS, מזהה ה-URI של מפתח ה-KEK הוא בפורמט הבא:

gcp-kms://projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY_NAME/cryptoKeyVersions/KEY_VERSION

במאמר קבלת מזהה משאב של Cloud KMS מוסבר איך לקבל את הנתיב למפתח.

הפעלת Tink והצפנת נתונים

‫Tink משתמשת בפרימיטיבים – אבני בניין קריפטוגרפיות שמנהלות את הפרטים של האלגוריתמים הבסיסיים שלהן – כדי שתוכלו לבצע משימות בצורה בטוחה. כל פרימיטיב מציע API שמטפל במשימה ספציפית. במקרה הזה אנחנו משתמשים ב-AEAD, ולכן אנחנו משתמשים בפרימיטיב Tink AEAD.

Python

Python

מידע על התקנת ספריית הלקוח של Cloud KMS ושימוש בה מופיע במאמר ספריות הלקוח של Cloud KMS.

כדי לבצע אימות ב-Cloud KMS, אתם צריכים להגדיר את Application Default Credentials. מידע נוסף זמין במאמר הגדרת אימות לסביבת פיתוח מקומית.

לפני שמריצים דוגמאות קוד, צריך להגדיר את משתנה הסביבה GOOGLE_CLOUD_UNIVERSE_DOMAIN לערך s3nsapis.fr.

"""A command-line utility for performing file encryption using GCS.

It is inteded for use with small files, utilizes envelope encryption and
facilitates ciphertexts stored in GCS.
"""

from absl import app
from absl import flags
from absl import logging
from google.cloud import storage

import tink
from tink import aead
from tink.integration import gcpkms


FLAGS = flags.FLAGS

flags.DEFINE_enum('mode', None, ['encrypt', 'decrypt'],
                  'The operation to perform.')
flags.DEFINE_string('kek_uri', None,
                    'The Cloud KMS URI of the key encryption key.')
flags.DEFINE_string('gcp_credential_path', None,
                    'Path to the GCP credentials JSON file.')
flags.DEFINE_string('gcp_project_id', None,
                    'The ID of the GCP project hosting the GCS blobs.')
flags.DEFINE_string('local_path', None, 'Path to the local file.')
flags.DEFINE_string('gcs_blob_path', None, 'Path to the GCS blob.')


_GCS_PATH_PREFIX = 'gs://'


def main(argv):
  del argv  # Unused.

  # Initialise Tink
  aead.register()

  try:
    # Read the GCP credentials and setup client
    client = gcpkms.GcpKmsClient(FLAGS.kek_uri, FLAGS.gcp_credential_path)
  except tink.TinkError as e:
    logging.exception('Error creating GCP KMS client: %s', e)
    return 1

  # Create envelope AEAD primitive using AES256 GCM for encrypting the data
  try:
    remote_aead = client.get_aead(FLAGS.kek_uri)
    env_aead = aead.KmsEnvelopeAead(
        aead.aead_key_templates.AES256_GCM, remote_aead
    )
  except tink.TinkError as e:
    logging.exception('Error creating primitive: %s', e)
    return 1

  storage_client = storage.Client.from_service_account_json(
      FLAGS.gcp_credential_path)

  try:
    bucket_name, object_name = _get_bucket_and_object(FLAGS.gcs_blob_path)
  except ValueError as e:
    logging.exception('Error parsing GCS blob path: %s', e)
    return 1
  bucket = storage_client.bucket(bucket_name)
  blob = bucket.blob(object_name)
  associated_data = FLAGS.gcs_blob_path.encode('utf-8')

  if FLAGS.mode == 'encrypt':
    with open(FLAGS.local_path, 'rb') as input_file:
      output_data = env_aead.encrypt(input_file.read(), associated_data)
    blob.upload_from_string(output_data)

  elif FLAGS.mode == 'decrypt':
    ciphertext = blob.download_as_bytes()
    with open(FLAGS.local_path, 'wb') as output_file:
      output_file.write(env_aead.decrypt(ciphertext, associated_data))

  else:
    logging.error(
        'Unsupported mode %s. Please choose "encrypt" or "decrypt".',
        FLAGS.mode,
    )
    return 1


def _get_bucket_and_object(gcs_blob_path):
  """Extract bucket and object name from a GCS blob path.

  Args:
    gcs_blob_path: path to a GCS blob

  Returns:
    The bucket and object name of the GCS blob

  Raises:
    ValueError: If gcs_blob_path parsing fails.
  """
  if not gcs_blob_path.startswith(_GCS_PATH_PREFIX):
    raise ValueError(
        f'GCS blob paths must start with gs://, got {gcs_blob_path}')
  path = gcs_blob_path[len(_GCS_PATH_PREFIX):]
  parts = path.split('/', 1)
  if len(parts) < 2:
    raise ValueError(
        'GCS blob paths must be in format gs://bucket-name/object-name, '
        f'got {gcs_blob_path}')
  return parts[0], parts[1]

if __name__ == '__main__':
  flags.mark_flags_as_required([
      'mode', 'kek_uri', 'gcp_credential_path', 'gcp_project_id', 'local_path',
      'gcs_blob_path'])
  app.run(main)

Java

Java

מידע על התקנת ספריית הלקוח של Cloud KMS ושימוש בה מופיע במאמר ספריות הלקוח של Cloud KMS.

כדי לבצע אימות ב-Cloud KMS, אתם צריכים להגדיר את Application Default Credentials. מידע נוסף זמין במאמר הגדרת אימות לסביבת פיתוח מקומית.

לפני שמריצים דוגמאות קוד, צריך להגדיר את משתנה הסביבה GOOGLE_CLOUD_UNIVERSE_DOMAIN לערך s3nsapis.fr.

package gcs;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.storage.BlobId;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.StorageOptions;
import com.google.crypto.tink.Aead;
import com.google.crypto.tink.KmsClient;
import com.google.crypto.tink.aead.AeadConfig;
import com.google.crypto.tink.aead.KmsEnvelopeAead;
import com.google.crypto.tink.aead.PredefinedAeadParameters;
import com.google.crypto.tink.integration.gcpkms.GcpKmsClient;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
import java.util.Arrays;

/**
 * A command-line utility for encrypting small files with envelope encryption and uploading the
 * results to GCS.
 *
 * <p>The CLI takes the following required arguments:
 *
 * <ul>
 *   <li>mode: "encrypt" or "decrypt" to indicate if you want to encrypt or decrypt.
 *   <li>kek-uri: The URI for the Cloud KMS key to be used for envelope encryption.
 *   <li>gcp-credential-file: Name of the file with the GCP credentials (in JSON format) that can
 *       access the Cloud KMS key and the GCS input/output blobs.
 *   <li>gcp-project-id: The ID of the GCP project hosting the GCS blobs that you want to encrypt or
 *       decrypt.
 * </ul>
 *
 * <p>When mode is "encrypt", it takes the following additional arguments:
 *
 * <ul>
 *   <li>local-input-file: Read the plaintext from this local file.
 *   <li>gcs-output-blob: Write the encryption result to this blob in GCS. The encryption result is
 *       bound to the location of this blob. That is, if you rename or move it to a different
 *       bucket, decryption will fail.
 * </ul>
 *
 * <p>When mode is "decrypt", it takes the following additional arguments:
 *
 * <ul>
 *   <li>gcs-input-blob: Read the ciphertext from this blob in GCS.
 *   <li>local-output-file: Write the decryption result to this local file.
 */
public final class GcsEnvelopeAeadExample {
  private static final String MODE_ENCRYPT = "encrypt";
  private static final String MODE_DECRYPT = "decrypt";
  private static final String GCS_PATH_PREFIX = "gs://";

  public static void main(String[] args) throws Exception {
    if (args.length != 6) {
      System.err.printf("Expected 6 parameters, got %d\n", args.length);
      System.err.println(
          "Usage: java GcsEnvelopeAeadExample encrypt/decrypt kek-uri gcp-credential-file"
              + " gcp-project-id input-file output-file");
      System.exit(1);
    }
    String mode = args[0];
    String kekUri = args[1];
    String gcpCredentialFilename = args[2];
    String gcpProjectId = args[3];

    // Initialise Tink: register all AEAD key types with the Tink runtime
    AeadConfig.register();

    // Read the GCP credentials and create a remote AEAD object.
    Aead remoteAead = null;
    try {
      KmsClient kmsClient = new GcpKmsClient().withCredentials(gcpCredentialFilename);
      remoteAead = kmsClient.getAead(kekUri);
    } catch (GeneralSecurityException ex) {
      System.err.println("Error initializing GCP client: " + ex);
      System.exit(1);
    }

    // Create envelope AEAD primitive using AES256 GCM for encrypting the data
    Aead aead = KmsEnvelopeAead.create(PredefinedAeadParameters.AES256_GCM, remoteAead);

    GoogleCredentials credentials =
        GoogleCredentials.fromStream(new FileInputStream(gcpCredentialFilename))
            .createScoped(Arrays.asList("https://www.googleapis.com/auth/cloud-platform"));
    Storage storage =
        StorageOptions.newBuilder()
            .setProjectId(gcpProjectId)
            .setCredentials(credentials)
            .build()
            .getService();

    // Use the primitive to encrypt/decrypt files.
    if (MODE_ENCRYPT.equals(mode)) {
      // Encrypt the local file
      byte[] input = Files.readAllBytes(Paths.get(args[4]));
      String gcsBlobPath = args[5];
      // This will bind the encryption to the location of the GCS blob. That if, if you rename or
      // move the blob to a different bucket, decryption will fail.
      // See https://developers.google.com/tink/aead#associated_data.
      byte[] associatedData = gcsBlobPath.getBytes(UTF_8);
      byte[] ciphertext = aead.encrypt(input, associatedData);

      // Upload to GCS
      String bucketName = getBucketName(gcsBlobPath);
      String objectName = getObjectName(gcsBlobPath);
      BlobId blobId = BlobId.of(bucketName, objectName);
      BlobInfo blobInfo = BlobInfo.newBuilder(blobId).build();
      storage.create(blobInfo, ciphertext);
    } else if (MODE_DECRYPT.equals(mode)) {
      // Download the GCS blob
      String gcsBlobPath = args[4];
      String bucketName = getBucketName(gcsBlobPath);
      String objectName = getObjectName(gcsBlobPath);
      byte[] input = storage.readAllBytes(bucketName, objectName);

      // Decrypt to a local file
      byte[] associatedData = gcsBlobPath.getBytes(UTF_8);
      byte[] plaintext = aead.decrypt(input, associatedData);
      File outputFile = new File(args[5]);
      try (FileOutputStream stream = new FileOutputStream(outputFile)) {
        stream.write(plaintext);
      }
    } else {
      System.err.println("The first argument must be either encrypt or decrypt, got: " + mode);
      System.exit(1);
    }

    System.exit(0);
  }

  private static String getBucketName(String gcsBlobPath) {
    if (!gcsBlobPath.startsWith(GCS_PATH_PREFIX)) {
      throw new IllegalArgumentException(
          "GCS blob paths must start with gs://, got " + gcsBlobPath);
    }

    String bucketAndObjectName = gcsBlobPath.substring(GCS_PATH_PREFIX.length());
    int firstSlash = bucketAndObjectName.indexOf("/");
    if (firstSlash == -1) {
      throw new IllegalArgumentException(
          "GCS blob paths must have format gs://my-bucket-name/my-object-name, got " + gcsBlobPath);
    }
    return bucketAndObjectName.substring(0, firstSlash);
  }

  private static String getObjectName(String gcsBlobPath) {
    if (!gcsBlobPath.startsWith(GCS_PATH_PREFIX)) {
      throw new IllegalArgumentException(
          "GCS blob paths must start with gs://, got " + gcsBlobPath);
    }

    String bucketAndObjectName = gcsBlobPath.substring(GCS_PATH_PREFIX.length());
    int firstSlash = bucketAndObjectName.indexOf("/");
    if (firstSlash == -1) {
      throw new IllegalArgumentException(
          "GCS blob paths must have format gs://my-bucket-name/my-object-name, got " + gcsBlobPath);
    }
    return bucketAndObjectName.substring(firstSlash + 1);
  }

  private GcsEnvelopeAeadExample() {}
}

מידע נוסף על הפרימיטיבים והממשקים שנתמכים על ידי Tink זמין בדף תחילת העבודה עם Tink.

מה השלב הבא?