Table of Contents
18.9 客户端实现要点
以下代码展示三端必须一致的密码原语。完整的握手、拦截器、Session single-flight 和请求重试参考根目录《APP接口加密方案-通用实施指南》。
18.9.1 Android:Kotlin + OkHttp
fun base64Url(bytes: ByteArray): String =
Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
fun encryptEnvelope(plainJson: ByteArray, key: ByteArray, aad: String): String {
require(key.size == 32)
val iv = ByteArray(12).also(SecureRandom()::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
cipher.updateAAD(aad.toByteArray(Charsets.UTF_8))
val encryptedAndTag = cipher.doFinal(plainJson)
val ciphertext = encryptedAndTag.copyOfRange(0, encryptedAndTag.size - 16)
val tag = encryptedAndTag.copyOfRange(encryptedAndTag.size - 16, encryptedAndTag.size)
val envelope = JSONObject()
.put("v", 1).put("n", base64Url(iv))
.put("c", base64Url(ciphertext)).put("t", base64Url(tag))
.toString()
return base64Url(envelope.toByteArray(Charsets.UTF_8))
}
Android 解密时把信封的 c 与 t 拼接后交给 Cipher.doFinal();Session Key 使用 Base64.URL_SAFE 解码并校验长度为 32。OkHttp 拦截器需要排除握手、multipart 和回调路径。
18.9.2 iOS:Swift + URLSession/CryptoKit
func encryptEnvelope(_ plaintext: Data, key: Data, aad: String) throws -> String {
guard key.count == 32 else { throw EncryptionError.invalidKey }
let sealed = try AES.GCM.seal(
plaintext,
using: SymmetricKey(data: key),
nonce: AES.GCM.Nonce(),
authenticating: Data(aad.utf8)
)
let object: [String: Any] = [
"v": 1,
"n": base64Url(Data(sealed.nonce)),
"c": base64Url(sealed.ciphertext),
"t": base64Url(sealed.tag)
]
return base64Url(try JSONSerialization.data(withJSONObject: object))
}
iOS 解密时使用 AES.GCM.SealedBox(nonce:ciphertext:tag:)。必须从最终 URLRequest.url.path 构造 AAD,并在加密 GET 后只保留 e 查询参数。
18.9.3 H5:TypeScript + axios + Web Crypto
async function encryptEnvelope(data: unknown, keyBytes: Uint8Array, aad: string) {
if (keyBytes.byteLength !== 32) throw new Error('AES key must be 32 bytes')
const iv = crypto.getRandomValues(new Uint8Array(12))
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt'])
const encrypted = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(aad), tagLength: 128 },
key,
new TextEncoder().encode(JSON.stringify(data)),
))
const ciphertext = encrypted.slice(0, -16)
const tag = encrypted.slice(-16)
return base64Url(new TextEncoder().encode(JSON.stringify({
v: 1, n: base64Url(iv), c: base64Url(ciphertext), t: base64Url(tag),
})))
}
Web Crypto 的 AES-GCM 加密结果是 ciphertext || tag,必须拆出最后 16 字节。axios 拦截器应区分 GET、JSON 和 FormData;FormData 直接放行。握手使用不挂业务拦截器的 axios 实例。