Tink discourages bad practices related to keys, such as:
- User access to secret key material – Instead, secret keys should be stored in a KMS whenever possible using one of the predefined ways in which Tink supports such systems.
- User access to parts of keys – Doing so often results in compatibility bugs.
In reality, there are cases that require violating these principles. Tink provides mechanisms to be able to do so safel which are described in the following sections.
Secret Key Access Tokens
In order to access secret key material, users have to have a token (which is
usually an object of some class, without any functionality). The token is
typically provided by a method such as InsecureSecretKeyAccess.get()
. Within
Google, users are prevented from using this function using Bazel BUILD
visibility. Outside of Google, security reviewers can search their
codebase for usages of this function.
One useful feature of these tokens is that they can be passed on. For example, suppose you have a function which serializes an arbitrary Tink key:
String serializeKey(Key key, @Nullable SecretKeyAccess secretKeyAccess);
For keys which have secret key material, this function requires the
secretKeyAccess
object to be non-null and have an actual SecretKeyAccess
token stored. For keys which don't have any secret material, the
secretKeyAccess
is ignored.
Given such a function, it is possible to write a function which serializes a whole keyset:
String serializeKeyset(KeysetHandle keyset, @Nullable SecretKeyAccess
secretKeyAccess);
This function calls serializeKey
for each key in the keyset internally, and
passes the given secretKeyAccess
to the underlying function. Users who then
call serializeKeyset
without the need to serialize secret key material can use
null
as the second argument. Users who need to serialize secret key material
should use InsecureSecretKeyAccess.get()
.
Access of parts of a key
Tink keys not only contain raw key material, but also metadata that specifies how the key should be used (and, in turn, that it shouldn't be used in any other way). For example, an RSA SSA PSS key in Tink specifies that this RSA key may only be used with the PSS signature algorithm using the specified hash function and specified salt length.
Sometimes, it is necessary to convert a Tink key into different formats that may not explicitly specify all this metadata. This usually means that the metadata needs to be provided when the key is used. In other words, assuming that the key is always used with the same algorithm, such a key still implicitly has the same same metadata, it is just stored in a different place.
When you convert a Tink key into a different format, you need to make sure that the Tink key's metadata matches the (implicitly specified) metadata of the other key format. If it doesn't match, the conversion must fail.
Because these checks are often missing or incomplete, Tink restricts access to APIs which give access to the key material which is only partial, but could be mistaken as a full key. In Java, Tink uses RestrictedApi for this, in C++ and Golang, it uses tokens similar to the secret key access tokens.
The users of these APIs are responsible for preventing both key reuse attacks and incompatibilities.
You most commonly encounter methods which restrict "partial key access" in the context of exporting keys from or importing keys to Tink. The following best practices explain how to safely operate in these scenarios.
Best Practice: Verify all parameters on key export
For example, if you write a function which exports an HPKE public key:
Bad way to export a public key:
/** Provide the key to our users which don't have Tink. */ byte[] exportTinkHpkeKey(HpkePublicKey key) { return key.getPublicKeyBytes().toByteArray(); }
This is problematic. After receiving the key, the third party using it makes some assumption on the key's parameters: for example, it will assume that the HPKE AEAD algorithm used for this key 256-bit was AES-GCM.
Recommendation: Verify the parameters are what you expect on key export.
Better way to export a public key:
/** 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(); }
Best Practice: Use Tink objects as early as possible on key import
This minimizes the risk of key confusion attacks, because the Tink Key object fully specifies the correct algorithm and stores all the metadata together with the key material.
Consider the following example:
Non-typed usage:
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); }
This is error prone: at the call site it is very easy to forget that you should
never use the same ecPoint
with another algorithm. For example, if a similar
function called encryptWithECHybridEncrypt
exists, the caller might use the
same curve point to encrypt a message, which can easily lead to vulnerabilities.
Instead, it is better to change verifyEcdsaSignature
so that the first
argument is EcdsaPublicKey
. In fact, whenever the key is read from disk or the
network, it should be immediately converted to an EcdsaPublicKey
object: at
this point you already know in which way the key is used, so it is best to
commit to it.
The preceding code can be improved even more. Instead of passing in a
EcdsaPublicKey
, passing in a KeysetHandle
is better. It prepares the code
for key rotation without any additional work. So this should be preferred.
The improvements aren't done, however: it is even better to pass in the
PublicKeyVerify
object: this is sufficient for this function, so passing in
the PublicKeyVerify
object potentially increases the places where this
function can be used. At this point however, the function becomes rather trivial
and can be inlined.
Recommendation: When key material is read from disk or the network for the first time, create the corresponding Tink objects as soon as possible.
Typed usage:
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(); }
Using such code, we immediately convert the byte-array to a Tink object when it is read, and we fully specify what algorithm should be used. This approach minimizes the probability of key confusion attacks.