Access Control

One goal of Tink is to discourage bad practices. Of particular interest in this section are two points:

  1. 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.
  2. 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.

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(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.

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