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

Tink keys don't only contain the raw key material, but also some metadata that exactly specifies how the key should be used. The key 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 a 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. So (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 (maybe 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 user of these APIs are responsible for preventing both key reuse attacks and incompatibilities.

You most commonly encounter methods which are restricted with "partial key access" when exporting keys from or importing keys to Tink.

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