5. Keystore密钥导入:importKey与importWrappedKey、密钥格式解析、安全传输通道建立
密钥导入,说白了就是把外部生成的密钥塞进Keystore里。很多人觉得这步很简单——不就是写个文件吗?嗯,如果你真这么想,那离踩坑就不远了。
我刚开始做安全模块时,也以为密钥导入就是个copy操作。直到有一次,我在调试一个支付SDK,发现导入的密钥在另一台设备上死活解不出来。查了两天,最后发现是密钥格式不对,Keystore根本不认。从那以后,我对密钥导入这件事就格外上心。
这一章,我们就来聊聊Keystore的两种导入方式:importKey和importWrappedKey。它们有什么区别?各自适合什么场景?密钥格式有哪些坑?安全传输通道怎么建?我一个一个说清楚。
5.1 两种导入方式:importKey vs importWrappedKey
先看官方定义。Android Keystore提供了两个API来导入密钥:
- importKey:直接导入明文密钥材料。密钥以明文形式传入Keystore,由Keystore内部加密存储。
- importWrappedKey:导入一个已经被加密包裹的密钥。密钥在外部加密后传入,Keystore内部解密后再存储。
你可能会问:既然importKey也能用,为什么还要搞个importWrappedKey?
原因很简单——安全传输。importKey要求密钥在传输过程中是明文的。如果你的密钥是从服务器下发的,那明文传输就意味着中间人攻击的风险。而importWrappedKey允许你在服务器端就把密钥加密好,客户端拿到的是密文,即使被截获也无所谓。
我个人习惯是:本地生成的密钥用importKey,远程下发的密钥用importWrappedKey。这个原则帮我避免了不少麻烦。
5.2 importKey:直接导入明文密钥
importKey的用法很直接。你准备好密钥材料,调用API就行。但这里有个关键点——密钥格式。
Keystore支持的密钥格式主要有三种:
| 格式 | 说明 | 适用场景 |
|---|---|---|
| PKCS#8 | 私钥的标准编码格式 | 导入私钥 |
| X.509 | 公钥证书的标准格式 | 导入公钥/证书 |
| RAW | 原始密钥字节 | 对称密钥(如AES) |
我遇到过最典型的坑是:有人把PKCS#1格式的私钥直接传给importKey,结果Keystore报错。为什么?因为Android Keystore只认PKCS#8。PKCS#1和PKCS#8虽然都是私钥编码,但结构不同。你需要先做格式转换。
来看一个示例:
// 导入一个AES对称密钥
KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder("my_aes_key")
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build();
KeyGenerator kg = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
kg.init(spec);
SecretKey key = kg.generateKey();
// 导出密钥字节(注意:这通常不推荐,仅用于演示)
byte[] keyMaterial = key.getEncoded();
// 重新导入(实际场景中,keyMaterial可能来自服务器)
KeyStore ks = KeyStore.getInstance("AndroidKeyStore");
ks.load(null);
ks.setEntry(
"imported_aes_key",
new KeyStore.SecretKeyEntry(
new SecretKeySpec(keyMaterial, "AES")),
null);
注意看,这里我用了setEntry而不是importKey。实际上,对于对称密钥,Android Keystore并没有直接的importKey方法,而是通过setEntry配合SecretKeyEntry来实现。这个细节很多人不知道,文档里也写得比较隐晦。
5.3 importWrappedKey:安全导入加密密钥
这才是重头戏。importWrappedKey是Android 9(API 28)引入的特性,专门解决密钥安全传输的问题。
它的工作流程是这样的:
- 客户端生成一个临时密钥对(称为“包装密钥”),将公钥发给服务器。
- 服务器用这个公钥加密要下发的密钥,生成一个“包裹”。
- 客户端收到包裹后,用私钥解密,再存入Keystore。
整个过程,密钥在网络上传输时始终是加密状态。即使被截获,攻击者也解不开。
来看一个完整的客户端示例:
// 1. 生成包装密钥对
KeyPairGenerator kpg = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore");
kpg.initialize(new KeyGenParameterSpec.Builder("wrapping_key")
.setDigests(KeyProperties.DIGEST_SHA256)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.setKeySize(2048)
.build());
KeyPair wrappingKeyPair = kpg.generateKeyPair();
// 2. 将公钥发给服务器(通过安全通道,如HTTPS)
byte[] publicKeyBytes = wrappingKeyPair.getPublic().getEncoded();
// 3. 服务器返回包裹(WrappedKeyEntry)
// 假设我们已经从服务器拿到了包裹数据
byte[] wrappedKeyBytes = ...; // 来自服务器
byte[] iv = ...; // 来自服务器
byte[] encryptedTransportKey = ...; // 来自服务器
// 4. 导入包裹密钥
KeyStore ks = KeyStore.getInstance("AndroidKeyStore");
ks.load(null);
WrappedKeyEntry entry = new WrappedKeyEntry(
wrappedKeyBytes,
"wrapping_key", // 包装密钥的别名
KeyProperties.KEY_ALGORITHM_AES,
null); // 额外参数
ks.setEntry("imported_wrapped_key", entry, null);
这里有个细节:WrappedKeyEntry的构造方法在Android 9之后才有。如果你的应用还要兼容低版本,那就得自己实现包裹解密逻辑。嗯,这确实是个麻烦事。
5.4 密钥格式解析:PKCS#8、X.509与RAW
聊完了导入方式,我们得说说密钥格式。格式不对,一切白费。
PKCS#8:这是私钥的标准格式。它包含算法标识符和私钥值。Android Keystore要求私钥必须是PKCS#8编码。如果你手头是PKCS#1格式,可以用OpenSSL转换:
openssl pkcs8 -topk8 -inform PEM -outform DER -in private_key.pem -out private_key.der -nocrypt
X.509:这是公钥证书的标准格式。它包含公钥、签名算法、颁发者、有效期等信息。导入证书时,Keystore会验证证书链。如果证书无效,导入会失败。
RAW:这是对称密钥的原始字节。比如AES-256密钥就是32个字节。导入时直接传字节数组就行。但要注意,RAW格式没有算法标识符,所以你需要额外指定算法。
我个人的经验是:尽量用标准格式,别自己发明格式。以前有个项目,团队自己定义了一套密钥编码,结果换了个Keystore实现就解析不了。折腾了两周才改回PKCS#8。
5.5 安全传输通道的建立
最后,我们聊聊安全传输通道。importWrappedKey虽然解决了密钥本身的加密,但包装密钥的公钥怎么传给服务器?这本身就是一个安全问题。
我的做法是:在HTTPS的基础上,再加一层签名验证。
具体来说:
- 客户端在生成包装密钥对后,用应用自身的签名证书对公钥进行签名。
- 服务器收到公钥后,验证签名是否来自合法的应用。
- 验证通过后,再用这个公钥加密要下发的密钥。
这样即使HTTPS被中间人攻击,攻击者也无法伪造合法的公钥。
来看一个简化的签名流程:
// 客户端:签名公钥
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey); // 应用自身的私钥
sig.update(wrappingPublicKeyBytes);
byte[] signature = sig.sign();
// 将 wrappingPublicKeyBytes 和 signature 一起发给服务器
// 服务器:验证签名
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(appPublicKey); // 应用的公钥(预置在服务器)
sig.update(wrappingPublicKeyBytes);
boolean valid = sig.verify(signature);
if (valid) {
// 用 wrappingPublicKeyBytes 加密密钥
}
这个方案我用了好几年,没出过问题。当然,前提是应用自身的签名证书要保护好,不能泄露。
- 本地密钥用importKey,远程密钥用importWrappedKey
- 私钥必须用PKCS#8格式,公钥用X.509格式
- importWrappedKey需要RSA 2048位以上的包装密钥
- 安全传输通道要在HTTPS基础上加签名验证
好了,密钥导入这块就聊这么多。你可能会觉得步骤有点多,但安全这东西,多一步就多一层保障。我见过太多因为省事而翻车的案例了。
这张图把两种导入方式的流程画清楚了。左边是importKey,密钥直接进去;右边是importWrappedKey,密钥先加密再进去。底部是安全传输通道,两种方式都需要,但importWrappedKey对通道的要求更高。
嗯,密钥导入就讲到这里。下一章我们聊聊密钥使用时的那些坑,尤其是签名验证和加密解密中的常见错误。到时候见。
公众号:蓝海资料掘金营,微信deep3321