Exporta material de clave de manera programática

Tink desaconseja las prácticas recomendadas relacionadas con las claves, como las que se mencionan a continuación:

  • Acceso de los usuarios al material de claves secretas. En su lugar, las claves secretas se deben almacenar en un KMS siempre que sea posible mediante una de las formas predefinidas en que Tink admite esos sistemas.
  • Acceso de los usuarios a partes de las claves: Hacerlo suele generar errores de compatibilidad.

En realidad, hay casos que requieren incumplir estos principios. Tink proporciona mecanismos para hacerlo de manera segura, que se describen en las siguientes secciones.

Tokens de acceso de clave secreta

Para acceder al material de la clave secreta, los usuarios deben tener un token (que suele ser un objeto de alguna clase, sin ninguna funcionalidad). Por lo general, un método como InsecureSecretKeyAccess.get() proporciona el token. Dentro de Google, los usuarios no pueden usar esta función mediante la visibilidad de COMPILACIÓN de Bazel. Fuera de Google, los revisores de seguridad pueden buscar en su base de código el uso de esta función.

Una función útil de estos tokens es que se pueden pasar. Por ejemplo, supongamos que tienes una función que serializa una clave de Tink arbitraria:

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

Para las claves que tienen material de clave secreta, esta función requiere que el objeto secretKeyAccess no sea nulo y que tenga almacenado un token SecretKeyAccess real. Para las claves que no tienen ningún material secreto, se ignora el secretKeyAccess.

Con una función de este tipo, es posible escribir una función que serialice un conjunto de claves completo:

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

Esta función llama a serializeKey para cada clave en el conjunto de claves internamente y pasa el secretKeyAccess determinado a la función subyacente. Los usuarios que luego llamen a serializeKeyset sin la necesidad de serializar el material de la clave secreta pueden usar null como segundo argumento. Los usuarios que necesitan serializar material de clave secreta deben usar InsecureSecretKeyAccess.get().

Acceso a las partes de una clave

Las claves de Tink no solo contienen material de clave sin procesar, sino también metadatos que especifican cómo se debe usar la clave (y, a su vez, que no se debe usar de ninguna otra manera). Por ejemplo, una clave RSA SSA PSS en Tink especifica que esta clave RSA solo se puede usar con el algoritmo de firma PSS mediante la función de hash y la longitud de sal especificadas.

A veces, es necesario convertir una clave Tink a diferentes formatos que pueden no especificar explícitamente todos estos metadatos. Por lo general, esto significa que se deben proporcionar los metadatos cuando se usa la clave. En otras palabras, si suponemos que la clave siempre se usa con el mismo algoritmo, esta clave aún tiene implícitamente los mismos metadatos, solo se almacena en un lugar diferente.

Cuando conviertes una clave de Tink a un formato diferente, debes asegurarte de que los metadatos de la clave de Tink coincidan con los metadatos (especificados de forma implícita) del otro formato de clave. De lo contrario, la conversión debe fallar.

Debido a que estas verificaciones suelen faltar o estar incompletas, Tink restringe el acceso a las APIs que proporcionan acceso al material de clave, que es solo parcial, pero que podría confundirse con una clave completa. En Java, Tink usa RestrictedApi para esto; en C++ y Golang, usa tokens similares a los tokens de acceso de clave secreta.

Los usuarios de estas APIs son responsables de evitar los ataques de reutilización de claves y las incompatibilidades.

Por lo general, te encuentras con métodos que restringen el "acceso parcial a la clave" en el contexto de la exportación o importación de claves a Tink. En las siguientes prácticas recomendadas, se explica cómo operar de forma segura en estas situaciones.

Práctica recomendada: Verifica todos los parámetros en la exportación de claves

Por ejemplo, si escribes una función que exporta una clave pública de HPKE, sucede lo siguiente:

Mala forma de exportar una clave pública:

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

Esto es un problema. Después de recibir la clave, el tercero que la usa hace algunas suposiciones sobre los parámetros de la clave: por ejemplo, supondrá que el algoritmo de AEAD de HPKE que se usó para esta clave de 256 bits fue AES-GCM.

Recomendación: Verifica que los parámetros sean los que esperas en la exportación de claves.

Mejor manera de exportar una clave pública:

/** 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();
}

Práctica recomendada: Usa objetos de Tink lo antes posible en la importación de claves

Esto minimiza el riesgo de ataques de confusión de claves, ya que el objeto Tink Key especifica por completo el algoritmo correcto y almacena todos los metadatos junto con el material de la clave.

Consulta el siguiente ejemplo:

Uso no tipado:

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(RegistryConfiguration.get(), PublicKeyVerify.class);
    publicKeyVerify.verify(signature, message);
}

Esto es propenso a errores: en el sitio de llamadas, es muy fácil olvidar que nunca debes usar el mismo ecPoint con otro algoritmo. Por ejemplo, si existe una función similar llamada encryptWithECHybridEncrypt, el llamador podría usar el mismo punto de curva para encriptar un mensaje, lo que puede generar vulnerabilidades con facilidad.

En su lugar, es mejor cambiar verifyEcdsaSignature para que el primer argumento sea EcdsaPublicKey. De hecho, cada vez que se lee la clave desde el disco o la red, se debe convertir de inmediato en un objeto EcdsaPublicKey: en este punto, ya sabes de qué manera se usa la clave, por lo que es mejor que te comprometas con ella.

El código anterior se puede mejorar aún más. En lugar de pasar un EcdsaPublicKey, es mejor pasar un KeysetHandle. Prepara el código para la rotación de claves sin ningún trabajo adicional. Por eso, debería ser preferible esto.

Sin embargo, las mejoras no terminan ahí: es aún mejor pasar el objeto PublicKeyVerify, ya que es suficiente para esta función, por lo que pasar el objeto PublicKeyVerify podría aumentar los lugares donde se puede usar esta función. Sin embargo, en este punto, la función se vuelve bastante trivial y se puede intercalar.

Recomendación: Cuando se lea el material de claves desde el disco o la red por primera vez, crea los objetos de Tink correspondientes lo antes posible.

Uso escrito:

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();
}

Con ese código, convertimos inmediatamente el array de bytes en un objeto Tink cuando se lee y especificamos por completo qué algoritmo se debe usar. Este enfoque minimiza la probabilidad de ataques de confusión clave.