ShiErFeng/shierfeng-fastapi-app/src/utils/transportCrypto.js
2026-07-24 09:43:10 +08:00

1237 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import config from "@/config";
import forge, { primeForgeRandomBytes } from "@/utils/transportForge";
import {
ensureTransportCryptoPolicyLoaded,
getTransportCryptoPolicy,
getTransportRequestPath,
shouldEncryptQuery,
shouldEncryptRequest,
shouldEncryptResponse,
} from "@/utils/transportCryptoPolicy";
const TRANSPORT_BASE_URL = config.baseUrl;
const TRANSPORT_ENABLE_HEADER = "X-Transport-Encrypt";
const TRANSPORT_KEY_ID_HEADER = "X-Key-Id";
const ENCRYPTED_RESPONSE_HEADER = "x-body-encrypted";
const DEFAULT_TRANSPORT_ENVELOPE_VERSION = "1";
const KEY_REFRESH_BUFFER_MIN_SECONDS = 30;
const KEY_REFRESH_BUFFER_MAX_SECONDS = 300;
const TRANSPORT_KEY_META_CACHE_KEY = "transportCryptoKeyMeta";
const TRANSPORT_RETRYABLE_ERROR_MESSAGES = new Set([
"Decryption failed",
"密钥版本不存在",
]);
const AES_GCM_TAG_LENGTH_BYTES = 16;
const FORGE_RANDOM_POOL_BYTES = 4096;
let cachedKeyMeta = null;
let inflightKeyMetaPromise = null;
/**
* 获取当前运行时的全局对象引用。
*
* @returns {Object|undefined} 全局对象
*/
function getRuntimeGlobal() {
if (typeof globalThis !== "undefined") {
return globalThis;
}
if (typeof self !== "undefined") {
return self;
}
if (typeof window !== "undefined") {
return window;
}
if (typeof global !== "undefined") {
return global;
}
return undefined;
}
/**
* 为 Forge 补齐运行时依赖的全局对象别名。
*
* @returns {void}
*/
function ensureForgeRuntimeGlobal() {
const runtimeGlobal = getRuntimeGlobal();
if (!runtimeGlobal) {
return;
}
if (typeof runtimeGlobal.self === "undefined") {
runtimeGlobal.self = runtimeGlobal;
}
if (typeof runtimeGlobal.window === "undefined") {
runtimeGlobal.window = runtimeGlobal;
}
if (typeof runtimeGlobal.global === "undefined") {
runtimeGlobal.global = runtimeGlobal;
}
}
/**
* 获取已完成运行时适配的 Forge 实例。
*
* @returns {Promise<Object>} Forge 实例
*/
async function getForge() {
ensureForgeRuntimeGlobal();
return forge;
}
/**
* 从请求头对象中读取指定字段。
*
* @param {Object} headers 请求头对象
* @param {string} name 请求头名称
* @returns {*} 请求头值
*/
function getHeaderValue(headers, name) {
if (!headers) {
return undefined;
}
return headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()];
}
/**
* 为请求头对象设置指定字段。
*
* @param {Object} headers 请求头对象
* @param {string} name 请求头名称
* @param {*} value 请求头值
* @returns {void}
*/
function setHeaderValue(headers, name, value) {
if (!headers) {
return;
}
headers[name] = value;
}
/**
* 获取 Uni 响应对象中的响应头映射,兼容 H5 与其它平台字段差异。
*
* @param {Object} response Uni 响应对象
* @returns {Object|undefined} 响应头对象
*/
function getResponseHeaders(response) {
return response?.header || response?.headers;
}
/**
* 将文本编码为 UTF-8 二进制字符串。
*
* @param {string} text 原始文本
* @returns {string} UTF-8 二进制字符串
*/
function encodeUtf8(text) {
if (typeof TextEncoder !== "undefined") {
return uint8ArrayToBytes(new TextEncoder().encode(String(text || "")));
}
return unescape(encodeURIComponent(String(text || "")));
}
/**
* 将 UTF-8 二进制字符串解码为文本。
*
* @param {string} bytes UTF-8 二进制字符串
* @returns {string} 解码后的文本
*/
function decodeUtf8(bytes) {
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(bytesToUint8Array(bytes));
}
return decodeURIComponent(escape(bytes));
}
/**
* 将 Uint8Array 转为 Forge 兼容的二进制字符串。
*
* @param {Uint8Array} uint8Array 字节数组
* @returns {string} 二进制字符串
*/
function uint8ArrayToBytes(uint8Array) {
return Array.from(uint8Array, (item) => String.fromCharCode(item)).join("");
}
/**
* 将二进制字符串还原为 Uint8Array。
*
* @param {string} bytes 二进制字符串
* @returns {Uint8Array} 字节数组
*/
function bytesToUint8Array(bytes) {
return Uint8Array.from(String(bytes || ""), (item) => item.charCodeAt(0));
}
/**
* 获取对象的内部类型标签。
*
* @param {*} value 待检测值
* @returns {string} 内部类型标签
*/
function getObjectTag(value) {
return Object.prototype.toString.call(value);
}
/**
* 判断对象是否为 ArrayBuffer 视图。
*
* @param {*} value 待检测值
* @returns {boolean} 是否为视图对象
*/
function isArrayBufferView(value) {
return typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value);
}
/**
* 判断对象是否表现为 ArrayBuffer。
*
* @param {*} value 待检测值
* @returns {boolean} 是否可按 ArrayBuffer 处理
*/
function isArrayBufferLike(value) {
return (
value instanceof ArrayBuffer ||
getObjectTag(value) === "[object ArrayBuffer]" ||
(value &&
typeof value === "object" &&
typeof value.byteLength === "number" &&
typeof value.slice === "function" &&
!("length" in value))
);
}
/**
* 判断对象是否为数组风格的字节容器。
*
* @param {*} value 待检测值
* @returns {boolean} 是否可按字节数组处理
*/
function isByteArrayLikeObject(value) {
return (
value &&
typeof value === "object" &&
typeof value.length === "number" &&
value.length >= 0
);
}
/**
* 尝试通过平台 Base64 API 归一化原生 ArrayBuffer 对象。
*
* @param {*} randomValues 平台返回的随机数字节对象
* @returns {string|null} 归一化后的字节串,失败时返回 null
*/
function tryNormalizeArrayBufferLikeResult(randomValues) {
const arrayBufferToBase64Api =
(typeof uni !== "undefined" && uni.arrayBufferToBase64) ||
(typeof wx !== "undefined" && wx.arrayBufferToBase64);
const base64ToArrayBufferApi =
(typeof uni !== "undefined" && uni.base64ToArrayBuffer) ||
(typeof wx !== "undefined" && wx.base64ToArrayBuffer);
if (!arrayBufferToBase64Api || !base64ToArrayBufferApi) {
return null;
}
try {
const base64Text = arrayBufferToBase64Api(randomValues);
const arrayBuffer = base64ToArrayBufferApi(base64Text);
return uint8ArrayToBytes(new Uint8Array(arrayBuffer));
} catch (error) {
return null;
}
}
/**
* 将平台返回的随机数字节结果统一转换为二进制字符串。
*
* @param {*} randomValues 平台返回结果
* @param {number} expectedLength 期望字节长度
* @returns {string} 归一化后的二进制字符串
*/
function normalizeRandomBytesResult(randomValues, expectedLength) {
if (randomValues instanceof Uint8Array) {
return uint8ArrayToBytes(randomValues);
}
if (isArrayBufferView(randomValues)) {
return uint8ArrayToBytes(
new Uint8Array(
randomValues.buffer,
randomValues.byteOffset || 0,
randomValues.byteLength,
),
);
}
if (randomValues instanceof ArrayBuffer) {
return uint8ArrayToBytes(new Uint8Array(randomValues));
}
if (Array.isArray(randomValues)) {
return uint8ArrayToBytes(Uint8Array.from(randomValues));
}
if (randomValues && typeof randomValues === "object") {
if (randomValues.randomValues) {
return normalizeRandomBytesResult(
randomValues.randomValues,
expectedLength,
);
}
if (randomValues.value) {
return normalizeRandomBytesResult(randomValues.value, expectedLength);
}
if (randomValues.data) {
return normalizeRandomBytesResult(randomValues.data, expectedLength);
}
if (randomValues.buffer instanceof ArrayBuffer) {
return normalizeRandomBytesResult(
new Uint8Array(
randomValues.buffer,
randomValues.byteOffset || 0,
randomValues.byteLength || expectedLength,
),
expectedLength,
);
}
if (isArrayBufferLike(randomValues)) {
const normalizedBytes = tryNormalizeArrayBufferLikeResult(randomValues);
if (normalizedBytes !== null) {
return normalizedBytes;
}
}
if (isByteArrayLikeObject(randomValues)) {
return uint8ArrayToBytes(Uint8Array.from(randomValues));
}
}
if (typeof randomValues === "string" && randomValues.length === expectedLength) {
return randomValues;
}
throw new Error(
`平台随机数返回结果格式不受支持: tag=${getObjectTag(randomValues)}, keys=${Object.keys(
randomValues || {},
).join(",")}`,
);
}
/**
* 请求平台随机数字节并统一为二进制字符串。
*
* @param {Function} api 平台随机数 API
* @param {number} length 需要的字节长度
* @returns {Promise<string>} 随机数字节串
*/
function requestPlatformRandomBytes(api, length) {
return new Promise((resolve, reject) => {
let settled = false;
const resolveOnce = (result) => {
if (settled) {
return;
}
settled = true;
resolve(normalizeRandomBytesResult(result, length));
};
const rejectOnce = (error) => {
if (settled) {
return;
}
settled = true;
reject(error);
};
try {
const maybeResult = api({
length,
success: resolveOnce,
fail: rejectOnce,
});
if (maybeResult && typeof maybeResult.then === "function") {
maybeResult.then(resolveOnce).catch(rejectOnce);
return;
}
if (
maybeResult &&
(maybeResult.randomValues || maybeResult.value || maybeResult.data)
) {
resolveOnce(maybeResult);
}
} catch (error) {
rejectOnce(error);
}
});
}
/**
* 在 app-plus iOS 端通过原生 NSUUID 获取随机字节兜底。
*
* @param {number} length 需要的字节长度
* @returns {string|null} 随机数字节串,当前平台不支持时返回 null
*/
function getAppPlusIosRandomBytes(length) {
// #ifdef APP-PLUS
if (typeof plus === "undefined") {
return null;
}
const osName = String(plus.os?.name || "").toLowerCase();
if (osName !== "ios" || typeof plus.ios?.importClass !== "function") {
return null;
}
try {
const uuidClass = plus.ios.importClass("NSUUID");
let randomBytes = "";
while (randomBytes.length < length) {
const uuidObject = plus.ios.invoke(uuidClass, "UUID");
const uuidText = String(plus.ios.invoke(uuidObject, "UUIDString") || "")
.replace(/-/g, "")
.toLowerCase();
plus.ios.deleteObject(uuidObject);
if (!uuidText) {
return null;
}
for (let index = 0; index < uuidText.length && randomBytes.length < length; index += 2) {
const byteValue = parseInt(uuidText.slice(index, index + 2), 16);
if (isNaN(byteValue)) {
return null;
}
randomBytes += String.fromCharCode(byteValue);
}
}
return randomBytes;
} catch (error) {
console.warn("iOS NSUUID 初始化失败,继续尝试其它随机数能力", error);
}
// #endif
return null;
}
/**
* 在 app-plus Android 端通过原生 SecureRandom 获取随机字节。
*
* @param {number} length 需要的字节长度
* @returns {string|null} 随机数字节串,当前平台不支持时返回 null
*/
function getAppPlusAndroidRandomBytes(length) {
// #ifdef APP-PLUS
if (typeof plus === "undefined") {
return null;
}
const osName = String(plus.os?.name || "").toLowerCase();
if (osName !== "android" || typeof plus.android?.importClass !== "function") {
return null;
}
try {
plus.android.importClass("java.security.SecureRandom");
const secureRandom =
typeof plus.android.newObject === "function"
? plus.android.newObject("java.security.SecureRandom")
: null;
if (!secureRandom) {
return null;
}
plus.android.importClass(secureRandom);
let randomBytes = "";
for (let index = 0; index < length; index += 1) {
randomBytes += String.fromCharCode(secureRandom.nextInt(256));
}
if (typeof plus.android.autoCollection === "function") {
plus.android.autoCollection(secureRandom);
}
return randomBytes;
} catch (error) {
console.warn("Android SecureRandom 初始化失败,继续尝试其它随机数能力", error);
}
// #endif
return null;
}
/**
* 获取当前运行环境下的安全随机数字节。
*
* @param {number} length 需要的字节长度
* @returns {Promise<string>} 随机数字节串
*/
async function getRandomBytes(length) {
const runtimeGlobal = getRuntimeGlobal();
const runtimeCrypto = runtimeGlobal?.crypto;
if (runtimeCrypto?.getRandomValues) {
const bytes = new Uint8Array(length);
runtimeCrypto.getRandomValues(bytes);
return uint8ArrayToBytes(bytes);
}
if (typeof uni !== "undefined" && typeof uni.getRandomValues === "function") {
return requestPlatformRandomBytes(uni.getRandomValues, length);
}
if (typeof wx !== "undefined" && typeof wx.getRandomValues === "function") {
return requestPlatformRandomBytes(wx.getRandomValues, length);
}
const appPlusRandomBytes =
getAppPlusAndroidRandomBytes(length) || getAppPlusIosRandomBytes(length);
if (appPlusRandomBytes !== null) {
return appPlusRandomBytes;
}
throw new Error("当前运行环境缺少安全随机数能力");
}
/**
* 预填充 Forge 需要的随机数字节池。
*
* @param {number} length 预填充长度
* @returns {Promise<void>}
*/
async function primeForgeRandomPool(length = FORGE_RANDOM_POOL_BYTES) {
primeForgeRandomBytes(await getRandomBytes(length));
}
/**
* 将二进制字符串编码为 Base64URL 文本。
*
* @param {string} bytes 二进制字符串
* @returns {string} Base64URL 文本
*/
function toBase64Url(bytes) {
const base64Text = uni.arrayBufferToBase64(bytesToUint8Array(bytes).buffer);
return base64Text.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
/**
* 将 Base64URL 文本还原为二进制字符串。
*
* @param {string} text Base64URL 文本
* @returns {string} 二进制字符串
*/
function fromBase64Url(text) {
const normalizedText = String(text || "").replace(/-/g, "+").replace(/_/g, "/");
const paddingLength = (4 - (normalizedText.length % 4 || 4)) % 4;
const arrayBuffer = uni.base64ToArrayBuffer(
normalizedText + "=".repeat(paddingLength),
);
return uint8ArrayToBytes(new Uint8Array(arrayBuffer));
}
/**
* 将查询信封编码为可放入 URL 的字符串。
*
* @param {Object} envelope 查询信封
* @returns {string} 编码后的查询参数值
*/
function encodeQueryEnvelope(envelope) {
return toBase64Url(encodeUtf8(JSON.stringify(envelope)));
}
/**
* 计算加密查询参数最终生成的 URL 长度。
*
* @param {string} url 请求地址
* @param {Object} params 查询参数
* @returns {number} URL 长度
*/
function buildQueryUrlLength(url = "", params = {}) {
const queryText = Object.keys(params)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join("&");
if (!queryText) {
return String(url || "").length;
}
const normalizedUrl = String(url || "");
const separator = normalizedUrl.includes("?") ? "&" : "?";
return `${normalizedUrl}${separator}${queryText}`.length;
}
/**
* 构建请求方向的 AAD 元数据。
*
* @param {Object} requestConfig 请求配置
* @returns {Object} 请求 AAD
*/
function buildRequestAad(requestConfig) {
return {
method: (requestConfig.method || "get").toUpperCase(),
path: getTransportRequestPath(
requestConfig.url,
requestConfig.baseUrl || TRANSPORT_BASE_URL,
),
};
}
/**
* 构建响应方向的 AAD 元数据。
*
* @param {Object} requestConfig 请求配置
* @returns {Object} 响应 AAD
*/
function buildResponseAad(requestConfig) {
return {
method: (requestConfig.method || "get").toUpperCase(),
path: getTransportRequestPath(
requestConfig.url,
requestConfig.baseUrl || TRANSPORT_BASE_URL,
),
direction: "response",
};
}
/**
* 将空值载荷规范化为可序列化对象。
*
* @param {*} payload 原始载荷
* @returns {*} 规范化后的载荷
*/
function normalizePlainPayload(payload) {
if (payload === undefined || payload === null) {
return {};
}
return payload;
}
/**
* 将请求载荷序列化为 JSON 文本。
*
* @param {*} payload 原始载荷
* @returns {string} JSON 文本
*/
function stringifyPayload(payload) {
return JSON.stringify(normalizePlainPayload(payload));
}
/**
* 克隆请求配置中的可变字段,便于失败重试恢复。
*
* @param {*} value 待克隆值
* @returns {*} 克隆结果
*/
function cloneRequestValue(value) {
if (value === undefined || value === null) {
return value;
}
const runtimeGlobal = getRuntimeGlobal();
if (typeof runtimeGlobal?.structuredClone === "function") {
return runtimeGlobal.structuredClone(value);
}
if (typeof value === "object") {
return JSON.parse(JSON.stringify(value));
}
return value;
}
/**
* 将信封字段转换为适合表单提交的字符串。
*
* @param {*} value 字段值
* @returns {string} 序列化文本
*/
function stringifyEnvelopeField(value) {
if (value && typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
/**
* 将加密信封编码为 x-www-form-urlencoded 文本。
*
* @param {Object} envelope 信封对象
* @returns {string} 表单编码文本
*/
function encodeFormEnvelope(envelope) {
return Object.entries(envelope)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
stringifyEnvelopeField(value),
)}`,
)
.join("&");
}
/**
* 将输入解析为 JSON 对象并校验结构。
*
* @param {*} payload 原始数据
* @param {string} errorMessage 失败提示
* @returns {Object} 解析后的对象
*/
function parseJsonObject(payload, errorMessage) {
const parsedPayload = typeof payload === "string" ? JSON.parse(payload) : payload;
if (!parsedPayload || typeof parsedPayload !== "object" || Array.isArray(parsedPayload)) {
throw new Error(errorMessage);
}
return parsedPayload;
}
/**
* 基于随机字节生成 UUID v4 字符串。
*
* @returns {Promise<string>} UUID 文本
*/
async function createUuid() {
const bytes = (await getRandomBytes(16))
.split("")
.map((item) => item.charCodeAt(0));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hexText = bytes
.map((item) => item.toString(16).padStart(2, "0"))
.join("");
return `${hexText.slice(0, 8)}-${hexText.slice(8, 12)}-${hexText.slice(
12,
16,
)}-${hexText.slice(16, 20)}-${hexText.slice(20)}`;
}
/**
* 校验公钥接口响应壳是否有效。
*
* @param {Object} responsePayload 公钥接口原始响应
* @returns {void}
*/
function validateTransportPublicKeyResponse(responsePayload) {
if (
responsePayload?.code !== 200 ||
!responsePayload?.data ||
typeof responsePayload.data !== "object"
) {
throw new Error(responsePayload?.msg || "获取传输层公钥失败");
}
}
/**
* 校验公钥业务载荷是否满足当前协议要求。
*
* @param {Object} payload 公钥业务载荷
* @param {Object} transportPolicy 当前传输策略
* @returns {void}
*/
function validateTransportPublicKeyPayload(payload, transportPolicy) {
if (!payload?.publicKey || !payload?.kid) {
throw new Error("获取传输层公钥失败");
}
if (
String(payload.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION) !==
transportPolicy.envelopeVersion
) {
throw new Error("传输层公钥协议版本不受支持");
}
if (payload.alg !== transportPolicy.requestEnvelopeAlgorithm) {
throw new Error("传输层公钥算法不受支持");
}
}
/**
* 校验响应信封与当前请求上下文是否一致。
*
* @param {Object} envelope 响应信封
* @param {Object} response 原始响应对象
* @param {Object} requestConfig 请求配置
* @param {Object} transportContext 请求加密上下文
* @param {Object} transportPolicy 当前传输策略
* @returns {void}
*/
function validateResponseEnvelope(
envelope,
response,
requestConfig,
transportContext,
transportPolicy,
) {
const expectedAad = buildResponseAad(requestConfig);
const responseKid = getHeaderValue(
getResponseHeaders(response),
TRANSPORT_KEY_ID_HEADER,
);
const aad = envelope.aad;
if (String(envelope.v || "") !== transportPolicy.envelopeVersion) {
throw new Error("传输层响应协议版本不受支持");
}
if (String(envelope.alg || "") !== transportPolicy.responseEnvelopeAlgorithm) {
throw new Error("传输层响应算法不受支持");
}
if (String(envelope.kid || "") !== String(transportContext.kid)) {
throw new Error("传输层响应密钥版本不匹配");
}
if (responseKid && String(envelope.kid) !== String(responseKid)) {
throw new Error("传输层响应头与响应体密钥版本不一致");
}
if (!aad || typeof aad !== "object" || Array.isArray(aad)) {
throw new Error("传输层响应AAD不合法");
}
if (
String(aad.method || "").toUpperCase() !== expectedAad.method ||
String(aad.path || "") !== expectedAad.path
) {
throw new Error("传输层响应的method/path与当前请求不匹配");
}
if (String(aad.direction || "") !== expectedAad.direction) {
throw new Error("传输层响应方向标识不合法");
}
}
/**
* 记录原始请求快照,供密钥刷新后重试恢复。
*
* @param {Object} requestConfig 请求配置
* @returns {void}
*/
function rememberOriginalRequestSnapshot(requestConfig) {
if (requestConfig.__transportOriginalSnapshot) {
return;
}
requestConfig.__transportOriginalSnapshot = {
url: requestConfig.url,
params: cloneRequestValue(requestConfig.params),
data: cloneRequestValue(requestConfig.data),
contentType: getHeaderValue(requestConfig.header, "Content-Type"),
};
}
/**
* 获取当前 Unix 秒级时间戳。
*
* @returns {number} 当前时间戳
*/
function getNowTimestamp() {
return Math.floor(Date.now() / 1000);
}
/**
* 根据公钥有效期计算本地提前刷新时间。
*
* @param {number} expireAt 公钥过期时间
* @param {number} fetchedAt 公钥获取时间
* @returns {number} 建议刷新时间
*/
function buildKeyRefreshAt(expireAt, fetchedAt = getNowTimestamp()) {
const normalizedExpireAt = Number(expireAt || 0);
const normalizedFetchedAt = Number(fetchedAt || 0);
const ttlSeconds = Math.max(normalizedExpireAt - normalizedFetchedAt, 0);
if (!normalizedExpireAt || !ttlSeconds) {
return 0;
}
const refreshBufferSeconds = Math.min(
KEY_REFRESH_BUFFER_MAX_SECONDS,
Math.max(KEY_REFRESH_BUFFER_MIN_SECONDS, Math.floor(ttlSeconds * 0.1)),
);
return Math.max(normalizedFetchedAt, normalizedExpireAt - refreshBufferSeconds);
}
/**
* 判断当前缓存的公钥元数据是否仍可使用。
*
* @param {Object} keyMeta 公钥元数据
* @param {number} nowTimestamp 当前时间戳
* @returns {boolean} 是否仍可使用
*/
function isUsableKeyMeta(keyMeta, nowTimestamp = getNowTimestamp()) {
if (!keyMeta?.publicKeyPem || !keyMeta?.kid || !keyMeta?.expireAt) {
return false;
}
const refreshAt = Number(
keyMeta.refreshAt ||
buildKeyRefreshAt(keyMeta.expireAt, keyMeta.fetchedAt || nowTimestamp),
);
return refreshAt > nowTimestamp;
}
/**
* 请求后端公钥接口。
* 这里故意直接使用原始 uni.request避免在获取公钥前再次进入统一 request
* 包装器而形成“加密请求依赖公钥、公钥请求又依赖加密请求”的启动环路。
*
* @param {string} publicKeyUrl 公钥接口地址
* @returns {Promise<Object>} Uni 请求响应
*/
function requestPublicKey(publicKeyUrl) {
return new Promise((resolve, reject) => {
uni.request({
url: `${TRANSPORT_BASE_URL}${publicKeyUrl}`,
method: "GET",
timeout: 10000,
success: resolve,
fail: reject,
});
});
}
/**
* 获取当前可用的后端公钥元信息。
*
* @param {boolean} forceRefresh 是否强制刷新
* @returns {Promise<Object>} 公钥元信息
*/
async function getTransportKeyMeta(forceRefresh = false) {
const transportPolicy = await ensureTransportCryptoPolicyLoaded();
const nowTimestamp = getNowTimestamp();
if (!forceRefresh && !cachedKeyMeta) {
const persistedKeyMeta = uni.getStorageSync(TRANSPORT_KEY_META_CACHE_KEY);
if (isUsableKeyMeta(persistedKeyMeta, nowTimestamp)) {
cachedKeyMeta = {
kid: persistedKeyMeta.kid,
alg: persistedKeyMeta.alg,
envelopeVersion:
persistedKeyMeta.envelopeVersion || transportPolicy.envelopeVersion,
publicKeyPem: persistedKeyMeta.publicKeyPem,
expireAt: persistedKeyMeta.expireAt,
fetchedAt: persistedKeyMeta.fetchedAt || nowTimestamp,
refreshAt:
persistedKeyMeta.refreshAt ||
buildKeyRefreshAt(
persistedKeyMeta.expireAt,
persistedKeyMeta.fetchedAt || nowTimestamp,
),
};
}
}
if (!forceRefresh && isUsableKeyMeta(cachedKeyMeta, nowTimestamp)) {
return cachedKeyMeta;
}
if (inflightKeyMetaPromise) {
return inflightKeyMetaPromise;
}
inflightKeyMetaPromise = requestPublicKey(
transportPolicy.publicKeyUrl || "/transport/crypto/public-key",
)
.then((response) => {
const responsePayload = response.data || {};
const payload = responsePayload.data || {};
const fetchedAt = getNowTimestamp();
validateTransportPublicKeyResponse(responsePayload);
validateTransportPublicKeyPayload(payload, transportPolicy);
cachedKeyMeta = {
kid: payload.kid,
alg: payload.alg,
envelopeVersion: String(
payload.envelopeVersion || transportPolicy.envelopeVersion,
),
publicKeyPem: payload.publicKey,
expireAt: payload.expireAt,
fetchedAt,
refreshAt: buildKeyRefreshAt(payload.expireAt, fetchedAt),
};
uni.setStorageSync(TRANSPORT_KEY_META_CACHE_KEY, cachedKeyMeta);
inflightKeyMetaPromise = null;
return cachedKeyMeta;
})
.catch((error) => {
inflightKeyMetaPromise = null;
throw error;
});
return inflightKeyMetaPromise;
}
/**
* 使用 RSA-OAEP 加密当前请求的 AES 会话密钥。
*
* @param {string} publicKeyPem PEM 格式公钥
* @param {string} aesKeyBytes AES 会话密钥字节串
* @returns {Promise<string>} 加密后的会话密钥
*/
async function rsaEncryptAesKey(publicKeyPem, aesKeyBytes) {
const cryptoForge = await getForge();
await primeForgeRandomPool();
const publicKey = cryptoForge.pki.publicKeyFromPem(String(publicKeyPem || ""));
return publicKey.encrypt(aesKeyBytes, "RSA-OAEP", {
md: cryptoForge.md.sha256.create(),
mgf1: {
md: cryptoForge.md.sha256.create(),
},
});
}
/**
* 为当前请求构建一次性的传输加密上下文。
*
* @returns {Promise<Object>} 请求级传输上下文
*/
async function buildTransportContext() {
const keyMeta = await getTransportKeyMeta();
const aesKey = await getRandomBytes(32);
const encryptedAesKey = await rsaEncryptAesKey(keyMeta.publicKeyPem, aesKey);
return {
kid: keyMeta.kid,
alg: keyMeta.alg,
envelopeVersion: keyMeta.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION,
aesKey,
ek: toBase64Url(encryptedAesKey),
};
}
/**
* 使用 AES-GCM 对明文载荷执行信封加密。
*
* @param {Object} context 请求级传输上下文
* @param {string} plainText 明文内容
* @param {Object} aad AAD 元数据
* @returns {Promise<Object>} 加密信封
*/
async function encryptPayloadText(context, plainText, aad) {
const cryptoForge = await getForge();
const iv = await getRandomBytes(12);
const cipher = cryptoForge.cipher.createCipher("AES-GCM", context.aesKey);
cipher.start({
iv,
additionalData: encodeUtf8(JSON.stringify(aad)),
tagLength: AES_GCM_TAG_LENGTH_BYTES * 8,
});
cipher.update(cryptoForge.util.createBuffer(encodeUtf8(plainText)));
if (!cipher.finish()) {
throw new Error("Encryption failed");
}
const ciphertext = cipher.output.getBytes() + cipher.mode.tag.getBytes();
return {
v: context.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION,
kid: context.kid,
alg: context.alg,
ts: getNowTimestamp(),
nonce: await createUuid(),
ek: context.ek,
aad,
iv: toBase64Url(iv),
ct: toBase64Url(ciphertext),
};
}
/**
* 使用请求上下文中的 AES 密钥解密响应信封。
*
* @param {Object} envelope 响应信封
* @param {Object} context 请求级传输上下文
* @returns {Promise<string>} 解密后的明文
*/
async function decryptEnvelope(envelope, context) {
const cryptoForge = await getForge();
const encryptedPayload = fromBase64Url(envelope.ct);
if (encryptedPayload.length <= AES_GCM_TAG_LENGTH_BYTES) {
throw new Error("Decryption failed");
}
const ciphertext = encryptedPayload.slice(0, -AES_GCM_TAG_LENGTH_BYTES);
const tag = encryptedPayload.slice(-AES_GCM_TAG_LENGTH_BYTES);
const decipher = cryptoForge.cipher.createDecipher("AES-GCM", context.aesKey);
decipher.start({
iv: fromBase64Url(envelope.iv),
additionalData: encodeUtf8(JSON.stringify(envelope.aad || {})),
tagLength: AES_GCM_TAG_LENGTH_BYTES * 8,
tag,
});
decipher.update(cryptoForge.util.createBuffer(ciphertext));
if (!decipher.finish()) {
throw new Error("Decryption failed");
}
return decodeUtf8(decipher.output.getBytes());
}
/**
* 复用同一次请求内的传输上下文,避免重复生成密钥。
*
* @param {Object} requestConfig 请求配置
* @returns {Promise<Object>} 请求级传输上下文
*/
function getOrCreateTransportContext(requestConfig) {
if (requestConfig.__transportCryptoContextPromise) {
return requestConfig.__transportCryptoContextPromise;
}
requestConfig.__transportCryptoContextPromise = buildTransportContext();
return requestConfig.__transportCryptoContextPromise;
}
/**
* 对 Uni 请求配置执行传输层加密封装。
*
* @param {Object} requestConfig 请求配置
* @returns {Promise<Object>} 加密后的请求配置
*/
export async function encryptTransportRequest(requestConfig) {
const transportPolicy = await ensureTransportCryptoPolicyLoaded();
if (!shouldEncryptRequest(requestConfig, transportPolicy)) {
requestConfig.__transportCryptoEnabledForRequest = false;
return requestConfig;
}
rememberOriginalRequestSnapshot(requestConfig);
const transportContext = await getOrCreateTransportContext(requestConfig);
const contentType = String(
getHeaderValue(requestConfig.header, "Content-Type") || "application/json",
).toLowerCase();
const method = (requestConfig.method || "get").toLowerCase();
const requestAad = buildRequestAad(requestConfig);
if (
shouldEncryptQuery(requestConfig, transportPolicy) &&
(requestConfig.params || method === "get" || method === "delete")
) {
const queryEnvelope = await encryptPayloadText(
transportContext,
JSON.stringify(normalizePlainPayload(requestConfig.params)),
requestAad,
);
requestConfig.params = { __enc: encodeQueryEnvelope(queryEnvelope) };
if (
buildQueryUrlLength(requestConfig.url, requestConfig.params) >
Number(transportPolicy.maxEncryptedGetUrlLength || 4096)
) {
throw new Error(
"当前GET/DELETE请求参数加密后长度超限请改用POST请求或精简查询条件",
);
}
}
if (["post", "put", "patch", "delete"].includes(method)) {
const plainText = stringifyPayload(requestConfig.data);
const bodyEnvelope = await encryptPayloadText(
transportContext,
plainText,
requestAad,
);
if (contentType.includes("application/x-www-form-urlencoded")) {
requestConfig.data = encodeFormEnvelope(bodyEnvelope);
} else {
requestConfig.data = bodyEnvelope;
setHeaderValue(
requestConfig.header,
"Content-Type",
"application/json;charset=utf-8",
);
}
}
setHeaderValue(requestConfig.header, TRANSPORT_ENABLE_HEADER, "1");
setHeaderValue(requestConfig.header, TRANSPORT_KEY_ID_HEADER, transportContext.kid);
requestConfig.__transportCryptoContext = transportContext;
requestConfig.__transportCryptoEnabledForRequest = true;
return requestConfig;
}
/**
* 清空当前缓存的公钥元数据。
*
* @returns {void}
*/
export function invalidateTransportKeyMeta() {
cachedKeyMeta = null;
inflightKeyMetaPromise = null;
uni.removeStorageSync(TRANSPORT_KEY_META_CACHE_KEY);
}
/**
* 将被加密改写过的请求恢复为原始形态。
*
* @param {Object} requestConfig 请求配置
* @returns {Object} 恢复后的请求配置
*/
export function resetTransportRequestConfig(requestConfig) {
const originalSnapshot = requestConfig?.__transportOriginalSnapshot;
if (!requestConfig || !originalSnapshot) {
return requestConfig;
}
requestConfig.url = originalSnapshot.url;
requestConfig.params = cloneRequestValue(originalSnapshot.params);
requestConfig.data = cloneRequestValue(originalSnapshot.data);
if (originalSnapshot.contentType) {
setHeaderValue(requestConfig.header, "Content-Type", originalSnapshot.contentType);
}
delete requestConfig.__transportCryptoContext;
delete requestConfig.__transportCryptoContextPromise;
delete requestConfig.__transportCryptoEnabledForRequest;
return requestConfig;
}
/**
* 判断错误是否属于可通过刷新公钥进行重试的场景。
*
* @param {Object} responseOrError 响应对象或错误对象
* @returns {boolean} 是否可刷新密钥重试
*/
export function shouldRetryTransportWithFreshKey(responseOrError) {
const responseMsg = responseOrError?.data?.msg || responseOrError?.response?.data?.msg;
const errorMessage = responseOrError?.message;
return (
TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(responseMsg) ||
TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(errorMessage)
);
}
/**
* 解密成功响应中的传输层信封。
*
* @param {Object} response 原始响应对象
* @param {Object} requestConfig 请求配置
* @returns {Promise<Object>} 解密后的响应对象
*/
export async function decryptTransportResponse(response, requestConfig) {
const encryptedResponseFlag = String(
getHeaderValue(getResponseHeaders(response), ENCRYPTED_RESPONSE_HEADER) || "",
);
if (encryptedResponseFlag !== "1") {
return response;
}
if (!shouldEncryptResponse(requestConfig, getTransportCryptoPolicy())) {
return response;
}
const transportPolicy = getTransportCryptoPolicy();
const transportContext = requestConfig.__transportCryptoContext;
if (!transportContext) {
throw new Error("缺少响应解密上下文");
}
const envelope = parseJsonObject(response.data, "传输层响应信封格式不合法");
validateResponseEnvelope(
envelope,
response,
requestConfig,
transportContext,
transportPolicy,
);
const plaintext = await decryptEnvelope(envelope, transportContext);
response.data = JSON.parse(plaintext);
return response;
}
/**
* 尝试解密异常响应中的传输层信封。
*
* @param {Object} error 错误对象
* @param {Object} requestConfig 原始请求配置
* @returns {Promise<Object>} 原始或已解密的错误对象
*/
export async function decryptTransportErrorResponse(error, requestConfig) {
const response =
error?.response ||
(error && (error.data !== undefined || error.header || error.headers || error.statusCode)
? error
: null);
const encryptedResponseFlag = String(
getHeaderValue(getResponseHeaders(response), ENCRYPTED_RESPONSE_HEADER) || "",
);
if (!response || encryptedResponseFlag !== "1") {
return error;
}
if (!shouldEncryptResponse(requestConfig || {}, getTransportCryptoPolicy())) {
return error;
}
const transportPolicy = getTransportCryptoPolicy();
const transportContext = requestConfig?.__transportCryptoContext;
if (!transportContext) {
return error;
}
try {
const envelope = parseJsonObject(response.data, "传输层响应信封格式不合法");
validateResponseEnvelope(
envelope,
response,
requestConfig,
transportContext,
transportPolicy,
);
const plaintext = await decryptEnvelope(envelope, transportContext);
response.data = JSON.parse(plaintext);
if (!error.response) {
error.response = response;
}
} catch (decryptError) {
console.error(decryptError);
}
return error;
}