以编程方式导出密钥材料

Tink 不鼓励采用与键相关的以下不良做法:

  • 用户对 Secret Key 材料的访问权限 - 相反,应尽可能使用 Tink 支持的预定义方式之一,将 Secret Key 存储在 KMS 中。
  • 用户对密钥的某些部分进行访问 – 这样做通常会导致兼容性错误。

实际上,在某些情况下,需要违反这些原则。Tink 提供了一些机制来安全地执行此操作,这些机制在以下部分中进行了介绍。

密钥访问令牌

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

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

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

对于具有密钥材料的密钥,此函数要求 secretKeyAccess 对象非 null 且存储了实际的 SecretKeyAccess 令牌。对于没有任何密钥材料的密钥,系统会忽略 secretKeyAccess

如果有这样的函数,可以编写一个序列化整个密钥集的函数:

String serializeKeyset(KeysetHandle keyset, @Nullable SecretKeyAccess
secretKeyAccess);

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

访问密钥的各个部分

Tink 密钥不仅包含原始密钥材料,还包含用于指定密钥应如何使用(以及不应以任何其他方式使用)的元数据。例如,Tink 中的 RSA SSA PSS 密钥指定此 RSA 密钥只能与使用指定哈希函数和指定盐长度的 PSS 签名算法搭配使用。

有时,您需要将 Tink 密钥转换为其他格式,而这些格式可能不会明确指定所有这些元数据。这通常意味着,在使用密钥时需要提供元数据。换句话说,假设密钥始终与同一算法搭配使用,那么这样的密钥仍然隐式具有相同的元数据,只是存储在其他位置。

将 Tink 密钥转换为其他格式时,您需要确保 Tink 密钥的元数据与其他密钥格式的(隐式指定的)元数据匹配。如果不匹配,转换将会失败。

由于这些检查通常缺失或不完整,因此 Tink 会限制对 API 的访问,这些 API 会授予对仅部分密钥材料的访问权限,但可能会被误认为是完整密钥。在 Java 中,Tink 使用 RestrictedApi 来实现此目的;在 C++ 和 Golang 中,它使用类似于密钥访问令牌的令牌。

这些 API 的用户有责任防范密钥重用攻击和不兼容性。

在从 Tink 导出或向 Tink 导入密钥的上下文中,您最常遇到限制“部分密钥访问权限”的方法。以下最佳做法介绍了如何在这些场景中安全操作。

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

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

导出公钥的错误方式

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

这会带来问题。收到密钥后,使用该密钥的第三方会对密钥的参数做出一些假设:例如,它会假设为此 256 位密钥使用的 HPKE AEAD 算法是 AES-GCM。

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

导出公钥的更妥善方式:

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

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

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

请参考以下示例:

非类型化用法

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

这很容易出错:在调用点,很容易忘记绝不应将同一 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 对象,并完全指定应使用哪种算法。这种方法可最大限度地降低密钥混淆攻击的概率。