访问权限控制

Tink 的一个目标是反对错误做法。本部分尤其值得关注的是两点:

  1. Tink 鼓励用户以一种用户无法访问密钥材料的方式使用密钥材料。相反,应尽可能使用 Tink 支持此类系统的一种预定义方式将密钥存储在 KMS 中。
  2. Tink 会阻止用户访问密钥的某些部分,因为这样做通常会导致兼容性 bug。

当然,在实际操作中,有时必须同时违反这两项原则。 为此,Tink 提供了不同的机制。

密钥访问令牌

为了访问密钥材料,用户必须具备令牌(通常只是某个类的对象,而没有任何功能)。令牌通常由 InsecureSecretKeyAccess.get() 等方法提供。在 Google 中,系统通过 Bazel BUILD 可见性阻止用户使用此功能。在 Google 之外,安全审核人员可以在代码库中搜索此函数的使用情况。

这些令牌的一个实用功能是它们可以传递。例如,假设您有一个用于序列化任意 Tink 键的函数:

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

对于包含密钥材料的密钥,此函数要求 secretKeyAccess 对象为非 null 值,并且存储了实际的 SecretKeyAccess 令牌。对于不包含任何密钥材料的密钥,系统会忽略 secretKeyAccess

有了这样的函数,您可以编写一个对整个密钥集进行序列化的函数:String seriesizeKeyset(KeysetHandle keyset, @Nullable SecretKeyAccess secretKeyAccess);

此函数在内部针对密钥集中的每个按键调用 serializeKey,并将指定的 secretKeyAccess 传递给底层函数。如果用户随后调用 serializeKeyset 而无需序列化密钥材料,则可以使用 null 作为第二个参数。需要序列化密钥材料的用户需要使用 InsecureSecretKeyAccess.get()

访问密钥的某些部分

一个相对常见的安全错误是“密钥重用攻击”。如果用户在两种不同的设置(例如,用于计算签名和加密)中重复使用 RSA 密钥的模数 n 以及指数 de,则会发生这种情况1

处理加密密钥时,另一个相对常见的错误是指定密钥的一部分,然后“假定”元数据。例如,假设用户想要从 Tink 导出 RSASSA-PSS 公钥以与其他库一起使用。在 Tink 中,这些键包含以下几个部分:

  • 模数 n
  • 公开指数 e
  • 在内部使用的两个哈希函数的规范
  • 算法内部使用的盐的长度。

导出此类密钥时,您可能会忽略哈希函数和盐长度。这通常效果很好,因为其他库通常不需要哈希函数(例如,只需假设使用了 SHA256),并且 Tink 中使用的哈希函数恰好与其他库中所用的哈希函数相同(或者,哈希函数可能是专门选择的,以便与其他库搭配使用)。

不过,忽略哈希函数可能会是一个代价高昂的错误。为此,我们假设稍后向 Tink 密钥集添加了一个具有不同哈希函数的新密钥。然后,假设通过该方法导出密钥,并将其提供给业务合作伙伴,由业务合作伙伴将其与另一个库搭配使用。Tink 现在采用不同的内部哈希函数,并且无法验证签名。

在这种情况下,如果哈希函数与其他库的预期不符,导出密钥的函数应该会失败;否则,导出的密钥将毫无用处,因为它会创建不兼容的密文或签名。

为了防止出现此类错误,Tink 对符合以下条件的函数进行了限制:只能访问部分密钥材料,但可能会被误认为是完整密钥。例如,在 Java 中,Tink 使用 RestrictedApi 实现。

当用户使用此类注释时,他们要负责防范密钥重用攻击和不兼容性。

最佳实践:在导入密钥时尽早使用 Tink 对象

在从 Tink 导出密钥或将密钥导入 Tink 时,您最常遇到受“部分密钥访问”限制的方法。

这样可以最大限度地降低密钥混淆攻击的风险,因为 Tink 密钥对象会完全指定正确的算法,并将所有元数据与密钥材料一起存储。

请参考以下示例:

非类型化用法

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

这容易出错:在调用点,很容易忘记,绝不可以将同一 ecPoint 用于其他算法。例如,如果存在名为 encryptWithECHybridEncrypt 的类似函数,调用方可能会使用相同的曲线点来加密消息,而这很容易导致漏洞。

更好的做法是更改 verifyEcdsaSignature,使第一个参数为 EcdsaPublicKey。事实上,每当从磁盘或网络中读取密钥时,都应立即将其转换为 EcdsaPublicKey 对象:此时,您已经知道密钥的使用方式,因此最好向它提交密钥。

上述代码还可以进一步改进。最好是传入 KeysetHandle,而不是传入 EcdsaPublicKey。它可以为密钥轮替准备代码,而无需执行任何额外的操作。因此,最好使用该方法。

不过,还没有完成改进:最好传入 PublicKeyVerify 对象:这对此函数来说已经足够,因此传入 PublicKeyVerify 对象可能会增加此函数的适用位置。不过,此时该函数变得相当简单,可以内联。

建议:首次从磁盘或网络中读取密钥材料时,请尽快创建相应的 Tink 对象。

类型化用法

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

使用此类代码,我们在读取该字节数组时会立即将其转换为 Tink 对象,并完全指定应使用什么算法。这种方法可最大限度地降低发生密钥混淆攻击的可能性。

最佳实践:在导出密钥时验证所有参数

例如,如果您编写一个导出 HPKE 公钥的函数:

导出公钥的不良方法

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

这就存在问题。收到密钥后,使用它的第三方会对密钥的参数做出一些假设:例如,它会假设用于 256 位密钥的 HPKE AEAD 算法是 AES-GCM,依此类推。

建议:请验证参数是否符合您的密钥导出预期。

导出公钥的更好方法

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