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 解密时把信封的 ct 拼接后交给 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 和 FormDataFormData 直接放行。握手使用不挂业务拦截器的 axios 实例。