Программный экспорт ключевого материала

Tink не рекомендует использовать неэффективные методы работы с ключами, например:

  • Доступ пользователей к секретным ключам. Вместо этого секретные ключи должны храниться в KMS, если это возможно, с использованием одного из предопределенных способов, которыми Tink поддерживает такие системы.
  • Доступ пользователей к частям ключей. Это часто приводит к ошибкам совместимости.

На самом деле в некоторых случаях эти принципы приходится нарушать. В Tink есть механизмы, которые позволяют делать это безопасно. Они описаны в следующих разделах.

Токены доступа к секретному ключу

Чтобы получить доступ к материалу секретного ключа, пользователям нужен токен (обычно это объект какого-либо класса без функциональности). Обычно токен предоставляется методом, например InsecureSecretKeyAccess.get(). В Google пользователи не могут использовать эту функцию из-за видимости Bazel BUILD. Специалисты по безопасности за пределами Google могут искать в своей базе кода случаи использования этой функции.

Полезная особенность этих токенов заключается в том, что их можно передавать. Например, предположим, что у вас есть функция, которая сериализует произвольный ключ Tink:

String serializeKey(Key key, @Nullable SecretKeyAccess secretKeyAccess);

Для ключей, у которых есть секретный материал, эта функция требует, чтобы объект secretKeyAccess не был нулевым и имел фактический токен SecretKeyAccess. Для ключей, не содержащих секретных данных, значение secretKeyAccess игнорируется.

Имея такую функцию, можно написать функцию, которая сериализует весь набор ключей:

String serializeKeyset(KeysetHandle keyset, @Nullable SecretKeyAccess
secretKeyAccess);

Эта функция вызывает serializeKey для каждого ключа в наборе и передает указанный secretKeyAccess базовой функции. Пользователи, которые вызывают serializeKeyset без необходимости сериализации материала секретного ключа, могут использовать null в качестве второго аргумента. Если вам нужно сериализовать секретный ключ, используйте InsecureSecretKeyAccess.get().

Доступ к частям ключа

Ключи Tink содержат не только необработанные данные, но и метаданные, которые указывают, как должен использоваться ключ (и, в свою очередь, что его нельзя использовать никаким другим способом). Например, ключ RSA SSA PSS в Tink указывает, что этот ключ RSA можно использовать только с алгоритмом подписи PSS, используя указанную хеш-функцию и указанную длину соли.

Иногда требуется преобразовать ключ Tink в другие форматы, в которых могут быть явно указаны не все эти метаданные. Обычно это означает, что метаданные нужно предоставлять при использовании ключа. Другими словами, если ключ всегда используется с одним и тем же алгоритмом, у него по-прежнему неявно есть те же метаданные, просто они хранятся в другом месте.

При преобразовании ключа Tink в другой формат необходимо убедиться, что метаданные ключа Tink соответствуют (неявно указанным) метаданным другого формата ключа. Если он не совпадает, конверсия не должна выполняться.

Поскольку такие проверки часто отсутствуют или выполняются не полностью, Tink ограничивает доступ к API, которые предоставляют доступ к материалу ключа, который является лишь частичным, но может быть ошибочно принят за полный ключ. В Java для этого используется RestrictedApi, а в C++ и Golang – токены, похожие на токены доступа к секретным ключам.

Пользователи этих API несут ответственность за предотвращение атак с повторным использованием ключей и несовместимостей.

Чаще всего вы сталкиваетесь с методами, которые ограничивают "доступ к частичному ключу" в контексте экспорта ключей из Tink или импорта ключей в Tink. Ниже приведены рекомендации по работе в таких ситуациях.

Рекомендация. Проверяйте все параметры при экспорте ключей

Например, если вы пишете функцию, которая экспортирует открытый ключ HPKE:

Неправильный способ экспорта открытого ключа:

/** Provide the key to our users which don't have Tink. */
byte[] exportTinkHpkeKey(HpkePublicKey key) {
    return key.getPublicKeyBytes().toByteArray();
}

Это проблематично. Получив ключ, третья сторона, которая его использует, делает предположение о параметрах ключа. Например, она предполагает, что алгоритм HPKE AEAD, используемый для этого 256-битного ключа, – это AES-GCM.

Рекомендация. Убедитесь, что параметры соответствуют вашим ожиданиям в отношении экспорта ключей.

Более эффективный способ экспорта открытого ключа:

/** Provide the key to our users which don't have Tink. */
byte[] exportTinkHpkeKeyForOurUsers(HpkePublicKey key) {
    // Our users assume we use KEM_P256_HKDF_SHA256 for the KEM.
    if (!key.getParameters().getKemId().equals(HpkeParameters.KemId.KEM_P256_HKDF_SHA256)) {
        throw new IllegalArgumentException("Bad parameters");
    }
    // Our users assume we use HKDF SHA256 to create the key material.
    if (!key.getParameters().getKdfId().equals(HpkeParameters.KdfId.HKDF_SHA256)) {
        throw new IllegalArgumentException("Bad parameters");
    }
    // Our users assume that we use AES GCM with 256 bit keys.
    if (!key.getParameters().getAeadId().equals(HpkeParameters.AeadId.AES_256_GCM)) {
        throw new IllegalArgumentException("Bad parameters");
    }
    // Our users assume we follow the standard and don't add a Tink style prefix
    if (!key.getParameters().getVariant().equals(HpkeParameters.Variant.NO_PREFIX)) {
        throw new IllegalArgumentException("Bad parameters");
    }
    return key.getPublicKeyBytes().toByteArray();
}

Рекомендация. Используйте объекты Tink как можно раньше при импорте ключей

Это минимизирует риск атак с подменой ключа, поскольку объект Tink Key полностью определяет правильный алгоритм и хранит все метаданные вместе с материалом ключа.

Пример:

Использование без указания типа:

void verifyEcdsaSignature(ECPoint ecPoint, byte[] signature, byte[] message)
        throws Exception {
    EcdsaParameters parameters =
        EcdsaParameters.builder()
            .setSignatureEncoding(EcdsaParameters.SignatureEncoding.IEEE_P1363)
            .setCurveType(EcdsaParameters.CurveType.NIST_P256)
            .setHashType(EcdsaParameters.HashType.SHA256)
            .setVariant(EcdsaParameters.Variant.NO_PREFIX)
            .build();
    EcdsaPublicKey key =
        EcdsaPublicKey.builder()
            .setParameters(parameters)
            .setPublicPoint(ecPoint)
            .build();
    KeysetHandle handle = KeysetHandle.newBuilder()
       .addEntry(KeysetHandle.importKey(key).withFixedId(1).makePrimary())
       .build();
    PublicKeyVerify publicKeyVerify =
        handle.getPrimitive(TinkConfig2026.get(), PublicKeyVerify.class);
    publicKeyVerify.verify(signature, message);
}

Это может привести к ошибкам: при вызове легко забыть, что нельзя использовать один и тот же ecPoint с другим алгоритмом. Например, если существует похожая функция encryptWithECHybridEncrypt, вызывающая функция может использовать ту же точку кривой для шифрования сообщения, что может легко привести к уязвимостям.

Вместо этого лучше изменить verifyEcdsaSignature так, чтобы первым аргументом был EcdsaPublicKey. Фактически, когда ключ считывается с диска или из сети, он должен быть немедленно преобразован в объект EcdsaPublicKey. В этот момент вы уже знаете, как используется ключ, поэтому лучше всего зафиксировать это.

Предыдущий код можно улучшить ещё больше. Вместо EcdsaPublicKey лучше передавать KeysetHandle. Он подготавливает код для ротации ключей без дополнительных действий. Поэтому рекомендуется использовать именно его.

Однако это ещё не всё. Лучше передать объект PublicKeyVerify. Этого достаточно для функции, поэтому передача объекта PublicKeyVerify потенциально увеличивает количество мест, где можно использовать эту функцию. Однако на этом этапе функция становится довольно тривиальной и может быть встроена.

Рекомендация. Когда ключевой материал считывается с диска или из сети в первый раз, создайте соответствующие объекты Tink как можно скорее.

Использование типов:

KeysetHandle readEcdsaKeyFromFile(Path fileWithEcdsaKey) throws Exception {
    byte[] content = Files.readAllBytes(fileWithEcdsaKey);
    BigInteger x = new BigInteger(1, Arrays.copyOfRange(content, 0, 32));
    BigInteger y = new BigInteger(1, Arrays.copyOfRange(content, 32, 64));
    ECPoint point = new ECPoint(x, y);
    EcdsaParameters parameters =
        EcdsaParameters.builder()
            .setSignatureEncoding(EcdsaParameters.SignatureEncoding.IEEE_P1363)
            .setCurveType(EcdsaParameters.CurveType.NIST_P256)
            .setHashType(EcdsaParameters.HashType.SHA256)
            .setVariant(EcdsaParameters.Variant.NO_PREFIX)
            .build();
    EcdsaPublicKey key =
        EcdsaPublicKey.builder()
            .setParameters(parameters)
            .setPublicPoint(ecPoint)
            .build();
    return KeysetHandle.newBuilder()
       .addEntry(KeysetHandle.importKey(key).withFixedId(1).makePrimary())
       .build();
}

Используя такой код, мы сразу преобразуем массив байтов в объект Tink при его чтении и полностью указываем, какой алгоритм следует использовать. Такой подход минимизирует вероятность атак с подменой ключей.