|
@@ -1,324 +1,338 @@
|
|
|
-console.log("window.crypto =", window.crypto);
|
|
|
|
|
-console.log("window.crypto?.subtle =", window.crypto?.subtle);
|
|
|
|
|
-console.log("globalThis.crypto =", globalThis.crypto);
|
|
|
|
|
-console.log("globalThis.crypto?.subtle =", globalThis.crypto?.subtle);
|
|
|
|
|
|
|
+import forge from "node-forge";
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+// =========================
|
|
|
|
|
+// AES-256-GCM KEY
|
|
|
|
|
+// 后端提供的32字节base64密钥
|
|
|
|
|
+// =========================
|
|
|
|
|
+
|
|
|
|
|
+const API_CRYPTO_KEY =
|
|
|
|
|
+ "3MRp/hTEz310NX3BJuMmIYAdlby1Q4K4XPeVSxXm5tE=";
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+// =========================
|
|
|
|
|
+// 协议配置
|
|
|
|
|
+// =========================
|
|
|
|
|
+
|
|
|
const VERSION = 1;
|
|
const VERSION = 1;
|
|
|
|
|
|
|
|
const IV_BYTES = 12;
|
|
const IV_BYTES = 12;
|
|
|
|
|
|
|
|
const TAG_BYTES = 16;
|
|
const TAG_BYTES = 16;
|
|
|
|
|
|
|
|
|
|
+const TIMESTAMP_TOLERANCE_MS = 300 * 1000;
|
|
|
|
|
|
|
|
-const encoder = new TextEncoder();
|
|
|
|
|
|
|
|
|
|
-const decoder = new TextDecoder();
|
|
|
|
|
|
|
|
|
|
|
|
+// =========================
|
|
|
|
|
+// Base64URL
|
|
|
|
|
+// =========================
|
|
|
|
|
|
|
|
-// 你的后端生成的32字节base64密钥
|
|
|
|
|
-const API_KEY =
|
|
|
|
|
- "3MRp/hTEz310NX3BJuMmIYAdlby1Q4K4XPeVSxXm5tE=";
|
|
|
|
|
-
|
|
|
|
|
|
|
+function bytesToBase64Url(bytes) {
|
|
|
|
|
|
|
|
|
|
+ return forge.util.encode64(bytes)
|
|
|
|
|
+ .replace(/\+/g, "-")
|
|
|
|
|
+ .replace(/\//g, "_")
|
|
|
|
|
+ .replace(/=+$/g, "");
|
|
|
|
|
|
|
|
-let cryptoKey = null;
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-// base64url -> Uint8Array
|
|
|
|
|
function base64UrlToBytes(value) {
|
|
function base64UrlToBytes(value) {
|
|
|
|
|
|
|
|
|
|
+ if (!value) {
|
|
|
|
|
+ throw new Error("base64url为空");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
const base64 =
|
|
const base64 =
|
|
|
value
|
|
value
|
|
|
- .replace(/-/g, '+')
|
|
|
|
|
- .replace(/_/g, '/');
|
|
|
|
|
|
|
+ .replace(/-/g, "+")
|
|
|
|
|
+ .replace(/_/g, "/");
|
|
|
|
|
|
|
|
|
|
|
|
|
const padded =
|
|
const padded =
|
|
|
base64 +
|
|
base64 +
|
|
|
- '='.repeat(
|
|
|
|
|
|
|
+ "=".repeat(
|
|
|
(4 - base64.length % 4) % 4
|
|
(4 - base64.length % 4) % 4
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
- const binary =
|
|
|
|
|
- atob(padded);
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- return Uint8Array.from(
|
|
|
|
|
- binary,
|
|
|
|
|
- c => c.charCodeAt(0)
|
|
|
|
|
|
|
+ return forge.util.decode64(
|
|
|
|
|
+ padded
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-// Uint8Array -> base64url
|
|
|
|
|
-function bytesToBase64Url(bytes) {
|
|
|
|
|
-
|
|
|
|
|
- let binary = '';
|
|
|
|
|
|
|
+// =========================
|
|
|
|
|
+// 获取AES KEY
|
|
|
|
|
+// =========================
|
|
|
|
|
|
|
|
|
|
+function getKey() {
|
|
|
|
|
|
|
|
- bytes.forEach(
|
|
|
|
|
- b => {
|
|
|
|
|
- binary +=
|
|
|
|
|
- String.fromCharCode(b);
|
|
|
|
|
- }
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const key =
|
|
|
|
|
+ forge.util.decode64(
|
|
|
|
|
+ API_CRYPTO_KEY
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- return btoa(binary)
|
|
|
|
|
- .replace(/\+/g, '-')
|
|
|
|
|
- .replace(/\//g, '_')
|
|
|
|
|
- .replace(/=/g, '');
|
|
|
|
|
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ if (key.length !== 32) {
|
|
|
|
|
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ "AES KEY 必须为32字节"
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
-// 获取AES KEY
|
|
|
|
|
-async function getKey() {
|
|
|
|
|
|
|
|
|
|
|
|
+ return key;
|
|
|
|
|
|
|
|
- if (cryptoKey) {
|
|
|
|
|
- return cryptoKey;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- cryptoKey =
|
|
|
|
|
- await crypto.subtle.importKey(
|
|
|
|
|
|
|
+// =========================
|
|
|
|
|
+// AAD
|
|
|
|
|
+// =========================
|
|
|
|
|
|
|
|
- "raw",
|
|
|
|
|
|
|
+function getAAD(
|
|
|
|
|
+ direction,
|
|
|
|
|
+ ts
|
|
|
|
|
+) {
|
|
|
|
|
|
|
|
- base64UrlToBytes(
|
|
|
|
|
- API_KEY
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ return `api-content:v1:${direction}:${ts}`;
|
|
|
|
|
|
|
|
- {
|
|
|
|
|
- name: "AES-GCM"
|
|
|
|
|
- },
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- false,
|
|
|
|
|
|
|
|
|
|
- [
|
|
|
|
|
- "encrypt",
|
|
|
|
|
- "decrypt"
|
|
|
|
|
- ]
|
|
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
+// =========================
|
|
|
|
|
+// 随机IV
|
|
|
|
|
+// =========================
|
|
|
|
|
|
|
|
|
|
+function createIV() {
|
|
|
|
|
|
|
|
- return cryptoKey;
|
|
|
|
|
|
|
+ return forge.random.getBytesSync(
|
|
|
|
|
+ IV_BYTES
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-// AAD
|
|
|
|
|
-function getAAD(
|
|
|
|
|
- direction,
|
|
|
|
|
- ts
|
|
|
|
|
|
|
+// =========================
|
|
|
|
|
+// 加密
|
|
|
|
|
+// =========================
|
|
|
|
|
+
|
|
|
|
|
+export function encryptApiPayload(
|
|
|
|
|
+ data,
|
|
|
|
|
+ direction = "request"
|
|
|
) {
|
|
) {
|
|
|
|
|
|
|
|
- return encoder.encode(
|
|
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
|
|
|
- `api-content:v1:${direction}:${ts}`
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ const ts =
|
|
|
|
|
+ Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ const iv =
|
|
|
|
|
+ createIV();
|
|
|
|
|
|
|
|
-// 加密请求
|
|
|
|
|
-export async function encryptApiPayload(
|
|
|
|
|
- data,
|
|
|
|
|
- direction = "request"
|
|
|
|
|
-) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- const ts =
|
|
|
|
|
- Date.now();
|
|
|
|
|
|
|
+ const cipher =
|
|
|
|
|
+ forge.cipher.createCipher(
|
|
|
|
|
+ "AES-GCM",
|
|
|
|
|
+ getKey()
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- const iv =
|
|
|
|
|
- crypto.getRandomValues(
|
|
|
|
|
- new Uint8Array(
|
|
|
|
|
- IV_BYTES
|
|
|
|
|
- )
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ cipher.start({
|
|
|
|
|
+
|
|
|
|
|
+ iv,
|
|
|
|
|
+
|
|
|
|
|
+ additionalData:
|
|
|
|
|
+ getAAD(
|
|
|
|
|
+ direction,
|
|
|
|
|
+ ts
|
|
|
|
|
+ ),
|
|
|
|
|
|
|
|
|
|
+ tagLength:
|
|
|
|
|
+ TAG_BYTES * 8
|
|
|
|
|
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- const plaintext =
|
|
|
|
|
- encoder.encode(
|
|
|
|
|
- JSON.stringify(data)
|
|
|
|
|
- );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ cipher.update(
|
|
|
|
|
|
|
|
- const encrypted =
|
|
|
|
|
- new Uint8Array(
|
|
|
|
|
|
|
+ forge.util.createBuffer(
|
|
|
|
|
+ JSON.stringify(data),
|
|
|
|
|
+ "utf8"
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- await crypto.subtle.encrypt(
|
|
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- {
|
|
|
|
|
|
|
|
|
|
- name: "AES-GCM",
|
|
|
|
|
|
|
|
|
|
- iv,
|
|
|
|
|
|
|
+ const result =
|
|
|
|
|
+ cipher.finish();
|
|
|
|
|
|
|
|
- additionalData:
|
|
|
|
|
- getAAD(
|
|
|
|
|
- direction,
|
|
|
|
|
- ts
|
|
|
|
|
- ),
|
|
|
|
|
|
|
|
|
|
- tagLength:
|
|
|
|
|
- TAG_BYTES * 8
|
|
|
|
|
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ if (!result) {
|
|
|
|
|
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ "AES-GCM加密失败"
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- await getKey(),
|
|
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
|
|
|
- plaintext
|
|
|
|
|
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ const envelope = {
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ v: VERSION,
|
|
|
|
|
|
|
|
|
|
+ ts,
|
|
|
|
|
|
|
|
|
|
|
|
|
- // WebCrypto返回 ciphertext+tag
|
|
|
|
|
- const tag =
|
|
|
|
|
- encrypted.slice(
|
|
|
|
|
- -TAG_BYTES
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ iv:
|
|
|
|
|
+ bytesToBase64Url(
|
|
|
|
|
+ iv
|
|
|
|
|
+ ),
|
|
|
|
|
|
|
|
|
|
|
|
|
- const cipherText =
|
|
|
|
|
- encrypted.slice(
|
|
|
|
|
- 0,
|
|
|
|
|
- -TAG_BYTES
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ data:
|
|
|
|
|
+ bytesToBase64Url(
|
|
|
|
|
+ cipher.output.getBytes()
|
|
|
|
|
+ ),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ tag:
|
|
|
|
|
+ bytesToBase64Url(
|
|
|
|
|
+ cipher.mode.tag.bytes()
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- const envelope = {
|
|
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
- v: VERSION,
|
|
|
|
|
|
|
|
|
|
- ts,
|
|
|
|
|
|
|
|
|
|
- iv:
|
|
|
|
|
- bytesToBase64Url(iv),
|
|
|
|
|
|
|
+ const payload =
|
|
|
|
|
+ bytesToBase64Url(
|
|
|
|
|
|
|
|
|
|
+ forge.util.encodeUtf8(
|
|
|
|
|
|
|
|
- data:
|
|
|
|
|
- bytesToBase64Url(
|
|
|
|
|
- cipherText
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ JSON.stringify(
|
|
|
|
|
+ envelope
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- tag:
|
|
|
|
|
- bytesToBase64Url(
|
|
|
|
|
- tag
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ resolve(payload);
|
|
|
|
|
|
|
|
- return bytesToBase64Url(
|
|
|
|
|
|
|
|
|
|
- encoder.encode(
|
|
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
|
|
|
- JSON.stringify(
|
|
|
|
|
- envelope
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ reject(e);
|
|
|
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-// 解密响应
|
|
|
|
|
-export async function decryptApiPayload(
|
|
|
|
|
|
|
+// =========================
|
|
|
|
|
+// 解密
|
|
|
|
|
+// =========================
|
|
|
|
|
+
|
|
|
|
|
+export function decryptApiPayload(
|
|
|
payload,
|
|
payload,
|
|
|
direction = "response"
|
|
direction = "response"
|
|
|
) {
|
|
) {
|
|
|
|
|
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
|
|
|
- const envelope =
|
|
|
|
|
- JSON.parse(
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
|
|
|
- decoder.decode(
|
|
|
|
|
|
|
|
|
|
- base64UrlToBytes(
|
|
|
|
|
- payload
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ const envelope =
|
|
|
|
|
+ JSON.parse(
|
|
|
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ forge.util.decodeUtf8(
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ base64UrlToBytes(
|
|
|
|
|
+ payload
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- const iv =
|
|
|
|
|
- base64UrlToBytes(
|
|
|
|
|
- envelope.iv
|
|
|
|
|
- );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- const data =
|
|
|
|
|
- base64UrlToBytes(
|
|
|
|
|
- envelope.data
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ envelope.v !== VERSION
|
|
|
|
|
+ ) {
|
|
|
|
|
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ "协议版本错误"
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- const tag =
|
|
|
|
|
- base64UrlToBytes(
|
|
|
|
|
- envelope.tag
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- const encrypted =
|
|
|
|
|
- new Uint8Array(
|
|
|
|
|
|
|
+ const now =
|
|
|
|
|
+ Date.now();
|
|
|
|
|
|
|
|
- data.length +
|
|
|
|
|
- tag.length
|
|
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ Math.abs(
|
|
|
|
|
+ now - envelope.ts
|
|
|
|
|
+ )
|
|
|
|
|
+ >
|
|
|
|
|
+ TIMESTAMP_TOLERANCE_MS
|
|
|
|
|
+ ) {
|
|
|
|
|
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ "密文时间戳过期"
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- encrypted.set(data);
|
|
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
|
|
|
- encrypted.set(
|
|
|
|
|
- tag,
|
|
|
|
|
- data.length
|
|
|
|
|
- );
|
|
|
|
|
|
|
|
|
|
|
|
+ const decipher =
|
|
|
|
|
+ forge.cipher.createDecipher(
|
|
|
|
|
+ "AES-GCM",
|
|
|
|
|
+ getKey()
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
|
|
|
|
|
- const plaintext =
|
|
|
|
|
- await crypto.subtle.decrypt(
|
|
|
|
|
|
|
|
|
|
- {
|
|
|
|
|
|
|
+ decipher.start({
|
|
|
|
|
|
|
|
- name: "AES-GCM",
|
|
|
|
|
|
|
+ iv:
|
|
|
|
|
+ base64UrlToBytes(
|
|
|
|
|
+ envelope.iv
|
|
|
|
|
+ ),
|
|
|
|
|
|
|
|
- iv,
|
|
|
|
|
|
|
|
|
|
additionalData:
|
|
additionalData:
|
|
|
getAAD(
|
|
getAAD(
|
|
@@ -326,27 +340,73 @@ export async function decryptApiPayload(
|
|
|
envelope.ts
|
|
envelope.ts
|
|
|
),
|
|
),
|
|
|
|
|
|
|
|
|
|
+
|
|
|
tagLength:
|
|
tagLength:
|
|
|
- TAG_BYTES * 8
|
|
|
|
|
|
|
+ TAG_BYTES * 8,
|
|
|
|
|
|
|
|
- },
|
|
|
|
|
|
|
|
|
|
|
|
+ tag:
|
|
|
|
|
+ forge.util.createBuffer(
|
|
|
|
|
|
|
|
- await getKey(),
|
|
|
|
|
|
|
+ base64UrlToBytes(
|
|
|
|
|
+ envelope.tag
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- encrypted
|
|
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ decipher.update(
|
|
|
|
|
|
|
|
- return JSON.parse(
|
|
|
|
|
|
|
+ forge.util.createBuffer(
|
|
|
|
|
|
|
|
- decoder.decode(
|
|
|
|
|
- plaintext
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ base64UrlToBytes(
|
|
|
|
|
+ envelope.data
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ const result =
|
|
|
|
|
+ decipher.finish();
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ if (!result) {
|
|
|
|
|
+
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ "AES-GCM认证失败"
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ const plaintext =
|
|
|
|
|
+ decipher.output.toString(
|
|
|
|
|
+ "utf8"
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ resolve(
|
|
|
|
|
+ JSON.parse(
|
|
|
|
|
+ plaintext
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+
|
|
|
|
|
+ reject(e);
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
}
|
|
}
|