One goal of Tink is to discourage bad practices. Of particular interest in this section are two points:
- Tink encourages usage in such a way that users cannot access 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.
- Tink discourages users from accessing parts of keys, as doing so often results in compatibility bugs.
In practice, of course both of these principle have to be violated sometimes. For this, Tink provides different mechanisms.
Secret Key Access Tokens
In order to access secret key material, users have to have a token (which
typically just is 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 pass 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
which have the need to serialize secret key material need to use
InsecureSecretKeyAccess.get()
.
Access of parts of a key
A relatively common security bug is a "key reuse attack". This can occur when
users reuse for example the modulus n
and exponents d
and e
of a RSA key
in two different settings (e.g., to compute signatures and encryptions)1.
Another relatively common mistake when dealing with cryptographic keys is to specify part of the key, and then "assume" the metadata. For example, suppose a user wants to export an RSASSA-PSS public key from Tink for use with a different library. In Tink, these keys have the following parts:
- The modulus
n
- The public exponent
e
- The specification of the two hash functions used internally
- The length of the salt used internally in the algorithm.
When exporting such a key, you might ignore the hash functions and the salt length. This can often work fine, as often other libraries don't ask for the hash functions (and for example just assume SHA256 is used), and the hash function used in Tink is coincidentally the same as in the other library (or maybe the hash functions were chosen specifically so it works together with the other library).
Nevertheless, ignoring the hash functions would be a potentially expensive mistake. To see this, suppose that later a new key with a different hash function is added to the Tink keyset. Suppose that then, the key is exported with the method, and given to a business partner, which uses it with the other library. Tink now assumes a different internal hash function, and can't verify the signature.
In this case, the function exporting the key should fail if the hash function does not match what the other library expects: otherwise, the exported key is useless as it creates incompatible ciphertexts or signatures.
To prevent such mistakes, Tink restricts functions which give access to the key material which is only partial, but could be mistaken as a full key. For example, in Java Tink uses RestrictedApi for this.
When a user uses such an annotation, they are responsible for preventing both key reuse attacks and incompatibilities.
Best Practice: Use Tink objects as early as possible on key import
You most commonly encounter methods which are restricted with "partial key access" when exporting keys from or importing keys to Tink. Key Point: When importing key material, convert it to a Tink object as soon as possible. This minimizes the risk of key confusion attacks because Tink objects fully specify the correct algorithm and store it along 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(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
directly has
the advantage of automatically preparing the code for key
rotation. 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 the functions, 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.
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 do not 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, and so on.
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 do not have Tink. */ byte[] exportTinkHpkeKeyForOurUsers(HpkePublicKey key) { // Our users assume we use KEM_P256_HKDF_SHA256 for the KEM. if (!key.getParameters().getKeyId().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().getKeyId().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 do not add a Tink style prefix if (!key.getParameters().getVariant().equals(HpkeParameters.Variant.NO_PREFIX)) { throw new IllegalArgumentException("Bad parameters"); } return key.getPublicKeyBytes().toByteArray(); }