cyymall-cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/bin/cyy.js +1 -1
- package/package.json +33 -33
- package/src/biz.js +14 -14
- package/src/cli.js +234 -208
- package/src/commands/apiCall.js +149 -149
- package/src/commands/auth.js +193 -19
- package/src/commands/cart.js +55 -55
- package/src/commands/order.js +399 -327
- package/src/commands/product.js +56 -56
- package/src/commands/serve.js +84 -84
- package/src/commands/shop.js +287 -287
- package/src/config.js +82 -82
- package/src/embeddedCyyKeys.js +16 -0
- package/src/encrypt.js +434 -0
- package/src/http.js +30 -3
package/src/config.js
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const os = require("os");
|
|
6
|
-
|
|
7
|
-
const DEFAULT_BASE_URL = "https://dhcmall.ifoodbuy.com";
|
|
8
|
-
|
|
9
|
-
/** BuildConfig-style module prefixes (app-api-cli-spec §1.2) */
|
|
10
|
-
const MODULE_MAP = {
|
|
11
|
-
DEFAULT: "/mall-multishop",
|
|
12
|
-
BIZ: "/mall-biz",
|
|
13
|
-
OSS: "/mall-oss",
|
|
14
|
-
ORDER: "/mall-order",
|
|
15
|
-
PLATFORM: "/mall-platform",
|
|
16
|
-
PRODUCT: "/mall-product",
|
|
17
|
-
PAYMENT: "/mall-payment",
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function getBaseUrl() {
|
|
21
|
-
const u = process.env.CYY_BASE_URL || DEFAULT_BASE_URL;
|
|
22
|
-
return u.endsWith("/") ? u.slice(0, -1) : u;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getConfigDir() {
|
|
26
|
-
return path.join(os.homedir(), ".cyymall");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function getConfigPath() {
|
|
30
|
-
return path.join(getConfigDir(), "config.json");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function ensureConfigDir() {
|
|
34
|
-
const dir = getConfigDir();
|
|
35
|
-
if (!fs.existsSync(dir)) {
|
|
36
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @returns {Record<string, unknown>|null}
|
|
42
|
-
*/
|
|
43
|
-
function loadConfig() {
|
|
44
|
-
const p = getConfigPath();
|
|
45
|
-
if (!fs.existsSync(p)) return null;
|
|
46
|
-
try {
|
|
47
|
-
const raw = fs.readFileSync(p, "utf8");
|
|
48
|
-
return JSON.parse(raw);
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* @param {Record<string, unknown>} data
|
|
56
|
-
*/
|
|
57
|
-
function saveConfig(data) {
|
|
58
|
-
ensureConfigDir();
|
|
59
|
-
fs.writeFileSync(getConfigPath(), JSON.stringify(data, null, 2), "utf8");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Mask token for display
|
|
64
|
-
* @param {string} t
|
|
65
|
-
*/
|
|
66
|
-
function maskToken(t) {
|
|
67
|
-
if (!t || typeof t !== "string") return "";
|
|
68
|
-
if (t.length <= 12) return "***";
|
|
69
|
-
return `${t.slice(0, 8)}...${t.slice(-4)}`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
module.exports = {
|
|
73
|
-
DEFAULT_BASE_URL,
|
|
74
|
-
MODULE_MAP,
|
|
75
|
-
getBaseUrl,
|
|
76
|
-
getConfigDir,
|
|
77
|
-
getConfigPath,
|
|
78
|
-
ensureConfigDir,
|
|
79
|
-
loadConfig,
|
|
80
|
-
saveConfig,
|
|
81
|
-
maskToken,
|
|
82
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BASE_URL = "https://dhcmall.ifoodbuy.com";
|
|
8
|
+
|
|
9
|
+
/** BuildConfig-style module prefixes (app-api-cli-spec §1.2) */
|
|
10
|
+
const MODULE_MAP = {
|
|
11
|
+
DEFAULT: "/mall-multishop",
|
|
12
|
+
BIZ: "/mall-biz",
|
|
13
|
+
OSS: "/mall-oss",
|
|
14
|
+
ORDER: "/mall-order",
|
|
15
|
+
PLATFORM: "/mall-platform",
|
|
16
|
+
PRODUCT: "/mall-product",
|
|
17
|
+
PAYMENT: "/mall-payment",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function getBaseUrl() {
|
|
21
|
+
const u = process.env.CYY_BASE_URL || DEFAULT_BASE_URL;
|
|
22
|
+
return u.endsWith("/") ? u.slice(0, -1) : u;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getConfigDir() {
|
|
26
|
+
return path.join(os.homedir(), ".cyymall");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getConfigPath() {
|
|
30
|
+
return path.join(getConfigDir(), "config.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureConfigDir() {
|
|
34
|
+
const dir = getConfigDir();
|
|
35
|
+
if (!fs.existsSync(dir)) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {Record<string, unknown>|null}
|
|
42
|
+
*/
|
|
43
|
+
function loadConfig() {
|
|
44
|
+
const p = getConfigPath();
|
|
45
|
+
if (!fs.existsSync(p)) return null;
|
|
46
|
+
try {
|
|
47
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {Record<string, unknown>} data
|
|
56
|
+
*/
|
|
57
|
+
function saveConfig(data) {
|
|
58
|
+
ensureConfigDir();
|
|
59
|
+
fs.writeFileSync(getConfigPath(), JSON.stringify(data, null, 2), "utf8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Mask token for display
|
|
64
|
+
* @param {string} t
|
|
65
|
+
*/
|
|
66
|
+
function maskToken(t) {
|
|
67
|
+
if (!t || typeof t !== "string") return "";
|
|
68
|
+
if (t.length <= 12) return "***";
|
|
69
|
+
return `${t.slice(0, 8)}...${t.slice(-4)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
DEFAULT_BASE_URL,
|
|
74
|
+
MODULE_MAP,
|
|
75
|
+
getBaseUrl,
|
|
76
|
+
getConfigDir,
|
|
77
|
+
getConfigPath,
|
|
78
|
+
ensureConfigDir,
|
|
79
|
+
loadConfig,
|
|
80
|
+
saveConfig,
|
|
81
|
+
maskToken,
|
|
82
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RSA key material bundled for `sendCodeV2` hybrid crypto — same strings as Android
|
|
5
|
+
* `app/src/main/cpp/cyy_native_key.cpp` (`SERVER_PUBLIC_KEY` / `CLIENT_PRIVATE_KEY`).
|
|
6
|
+
*
|
|
7
|
+
* The CLI now always uses these bundled keys for验证码登录链路;不再从环境变量或文件覆盖。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SERVER_PUBLIC_KEY =
|
|
11
|
+
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmdMRwn1tKAotKpG5olkgvxBzty1Vd2Ms/Gi+GEBHNyy6jrAbLCyrZuDfQO1EXuZzSNJCdA+eDzPFOyaCIL6qSQKNlp65hSpDaLWVEmbkq64polgxCRextFXcjArVSGhNPLNNcPGkcP5c5pZsJWPo17X7UYqRV5gCHsG93PZtgErX7CMM3KytumTmfkAnXxGYhWHqZtNYcaz+NrTjPA829MhmYRG7GnVnbTU3szX8FFxnDEqbr8iNDg2Jj2vzQxLreAp+pG6cHH/hdCYm6PSgSgJoTEAg28iY/HXjPcnhcCK49wsp1buxKoAGevJSgEdwgFczkt6H1L7/9MYlGbydMQIDAQAB";
|
|
12
|
+
|
|
13
|
+
const CLIENT_PRIVATE_KEY =
|
|
14
|
+
"MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCx+zfpGV9vgxNoLYOOT2vuWrAdbKE73TLFZWapFFAZ4Ehtmt8vzq2R2JCujTKuuRMUBMyeNbxxLuPkq202HdlIh6nh78tqlS7uO/sQXNP2ym4IdTN7/+4QKvgmnPuKsHsVyoUPRUM1/A6GoNRIPrvfh0+BPYAACOwjC/J9T/WpJWcE2MfXJT4e5IU9wOcawJzxP0me9+g2SfnoXwMybTkQtZtwDTsHX0cj0xy6GDKrHNNaYqpaFL/yefeuLXqHQazB1XrykjMwQWGFcXESYFgNxQ+Kqj0WN5z5mfbHoiqnpGEElUG7kWH4n3qJNfOjSBb2VD08634XeVS8PD2IR4KLAgMBAAECggEAY8rh3Hb3hcTOutjaLI7ni2uZ18Wy2af68acbWq4jA3833Qy7G0kdFOuCo/xTcJgg1FakjAjBMg0ChpJ/15oA3KQEYsRbH9WpzLYg3VnPF08FRwS/60TirHoLTUsz1t2BBgTZemhxePFtC5tdq2xRVtG9iE99V4epLzVhr1WH/mAk3khs6UqJzd8ka+Cnx9sYv8jqdXdKO+SNkIpDSR6VWVfai1lSEabpSypxYPlRBubI/4KFmMXgrEg3rD5ymPaD0aZvKK5dXcMGR6JPDqUtSsrVmYqCv4lJ/q5lDNJVXRmlEMu1MzfJh7yhRqwF4YlnQwOGZ4OloKEwh6XNDsapkQKBgQDbkZMZ4v5ihn+1+aku8II5qcE6Z70xW4Sc0rTPJS3DstVmcsJUplg1tJ92kAFId1ci1g5bNRqQFtRWH1oslmPP3YXUXMOufDhPy16gy/sND7zqMzfd/wXpscWF776ngU+xRvgVRJF+uGXB4wfyNZtcZx/099IJrGwYBPUbrrrSXQKBgQDPgyzbC2H74Fb4vSc60NcfXH4V6kMzrSzS1DTe4XEgynqeY0zzwyeDOoj1xT99OhXdD2qxS/6mRxFoLTTf9DsWHZN1megO4XvWz7xoPavzObjVbi95iTm+QqhmHreJc5zjgCJO5ugOZGxuoBh9CRR1QFXPcgVI+LxUzRTa88KqBwKBgQDJEXuC9jL8KKzlCTbcDFVE1uZjRMKlY2iCcBYxp5tMHgV8JtU8zYPz0m9BFMiIAvlhpmJJNc0YbD+Qim96a1IF9ZdrjHOJ4qlysQr79y/0mxfl5HdhrPtOMYRvjceq/yjqb9IZL8yJHfXZYr1RGbQnHyzNmO/X+fSW3ltTOWRN5QKBgQDA4KkptEvP5PaR3qb/CSxbDwp27jamarl2AR5fZ+ZR10HfxGa7UFKCrD6vSja3++xke7bssrkv7nCkhxhVbVoaNUVhkrtaUYVc5du2fFQ+EBHX98GS0tTkHmsN+FEaJmbWIrxA4GkjL36F2LLKTU1BqpnX5qA48lGC9NgEp3vxswKBgQCMiR+J+264aQy6UySSmLnjZR1zFAjd17gLa/5lgvtme++kSiEFjed014t7I9IxAdS4tGT+6BOUpCCJrrngmSW0rowdXdZa09odrPPv3Nz/5kyKo2vYBVknN3ConbRgUc3K4Nk4iGpWxJ6l620FoMWVCbTLuuBgr1oaQUM4ZoJiNw==";
|
|
15
|
+
|
|
16
|
+
module.exports = { SERVER_PUBLIC_KEY, CLIENT_PRIVATE_KEY };
|
package/src/encrypt.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Response decrypt pipeline (same order as App), when a caller opts in via
|
|
5
|
+
* {@code http.request(..., { decryptHybridResponse: true, decryptResponsePrivateKey })} (e.g. send-code only):
|
|
6
|
+
*
|
|
7
|
+
* 1. {@link com.example.cyymall.nets.Intercepor.CyyEncryptInterceptor#decryptResponse} — read body UTF-8 string
|
|
8
|
+
* `encryptedJson`, then `decryptResponseDataFromJson(encryptedJson)`; if result empty, keep original body.
|
|
9
|
+
* 2. {@link com.example.cyymall.utils.CyyNetworkEncryptManager#decryptResponseDataFromJson} —
|
|
10
|
+
* `hybridEncryptResultFromJson(encryptedJson)` → `HybridEncryptResult` (or null) → `decryptResponseData` →
|
|
11
|
+
* {@code CyyEncryptUtil.hybridDecrypt}.
|
|
12
|
+
* 3. {@link com.example.cyymall.utils.CyyEncryptUtil#hybridEncryptResultFromJson} — Gson parses JSON, then
|
|
13
|
+
* explicitly copies `encrypteData` / `encrypteParam` / `encrypteParamExt` into a new {@code HybridEncryptResult}.
|
|
14
|
+
* 4. {@link com.example.cyymall.utils.CyyEncryptUtil#hybridDecrypt} — RSA unwrap AES key+IV, AES-CBC decrypt payload → plain String.
|
|
15
|
+
*
|
|
16
|
+
* Request path unchanged: header {@code encrypte: true}, body via {@code hybridEncrypt} with server public key.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require("crypto");
|
|
20
|
+
const embedded = require("./embeddedCyyKeys");
|
|
21
|
+
|
|
22
|
+
/** Avoid duplicate RSA unwrap debug lines when hybridDecrypt runs twice on the same envelope (e.g. http + auth). */
|
|
23
|
+
let lastHybridDebugRsaCiphertextTag = "";
|
|
24
|
+
|
|
25
|
+
function encryptDisabledByEnv() {
|
|
26
|
+
const v = process.env.CYY_ENCRYPT_OFF;
|
|
27
|
+
return v === "1" || v === "true";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PEM keeps newlines; raw Base64 from cpp / one-line env strips all whitespace (fixes paste + soft line-wrap).
|
|
32
|
+
* @param {string} raw
|
|
33
|
+
*/
|
|
34
|
+
function normalizeStoredKeyMaterial(raw) {
|
|
35
|
+
let t = String(raw ?? "").replace(/^\uFEFF/, "").trim();
|
|
36
|
+
if (!t) return "";
|
|
37
|
+
if (t.includes("BEGIN")) return t;
|
|
38
|
+
return t.replace(/\s/g, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Always use the bundled RSA key material from `embeddedCyyKeys.js`.
|
|
43
|
+
* @returns {{ serverPublicKey: string, clientPrivateKey: string }}
|
|
44
|
+
*/
|
|
45
|
+
function resolveEncryptKeys() {
|
|
46
|
+
return {
|
|
47
|
+
serverPublicKey: normalizeStoredKeyMaterial(embedded.SERVER_PUBLIC_KEY),
|
|
48
|
+
clientPrivateKey: normalizeStoredKeyMaterial(embedded.CLIENT_PRIVATE_KEY),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} s
|
|
54
|
+
*/
|
|
55
|
+
function normalizeBase64(s) {
|
|
56
|
+
let t = String(s || "")
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/\s/g, "")
|
|
59
|
+
.replace(/-/g, "+")
|
|
60
|
+
.replace(/_/g, "/");
|
|
61
|
+
const pad = t.length % 4;
|
|
62
|
+
if (pad) t += "=".repeat(4 - pad);
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} raw
|
|
68
|
+
*/
|
|
69
|
+
function loadPublicKey(raw) {
|
|
70
|
+
const s = String(raw || "").trim();
|
|
71
|
+
if (!s) throw new Error("empty RSA public key");
|
|
72
|
+
if (s.includes("BEGIN")) {
|
|
73
|
+
return crypto.createPublicKey(s);
|
|
74
|
+
}
|
|
75
|
+
return crypto.createPublicKey({
|
|
76
|
+
key: Buffer.from(normalizeBase64(s), "base64"),
|
|
77
|
+
format: "der",
|
|
78
|
+
type: "spki",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} raw
|
|
84
|
+
*/
|
|
85
|
+
function loadPrivateKey(raw) {
|
|
86
|
+
const s = String(raw || "").trim();
|
|
87
|
+
if (!s) throw new Error("empty RSA private key");
|
|
88
|
+
if (s.includes("BEGIN")) {
|
|
89
|
+
return crypto.createPrivateKey(s);
|
|
90
|
+
}
|
|
91
|
+
return crypto.createPrivateKey({
|
|
92
|
+
key: Buffer.from(normalizeBase64(s), "base64"),
|
|
93
|
+
format: "der",
|
|
94
|
+
type: "pkcs8",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function generateAesKey() {
|
|
99
|
+
return crypto.randomBytes(32).toString("base64");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function generateAesIv() {
|
|
103
|
+
return crypto.randomBytes(16).toString("base64");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Java/Android `Cipher.getInstance("AES/CBC/...")` infers AES-128/192/256 from key length; Node requires an explicit algorithm name.
|
|
108
|
+
* @param {number} keyByteLength
|
|
109
|
+
* @returns {"aes-128-cbc" | "aes-192-cbc" | "aes-256-cbc" | null}
|
|
110
|
+
*/
|
|
111
|
+
function aesCbcAlgorithmForKeyLength(keyByteLength) {
|
|
112
|
+
if (keyByteLength === 16) return "aes-128-cbc";
|
|
113
|
+
if (keyByteLength === 24) return "aes-192-cbc";
|
|
114
|
+
if (keyByteLength === 32) return "aes-256-cbc";
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function aesEncryptWithBase64Key(data, keyBase64, ivBase64) {
|
|
119
|
+
const key = Buffer.from(normalizeBase64(keyBase64), "base64");
|
|
120
|
+
const iv = Buffer.from(normalizeBase64(ivBase64), "base64");
|
|
121
|
+
const algo = aesCbcAlgorithmForKeyLength(key.length);
|
|
122
|
+
if (!algo) {
|
|
123
|
+
throw new Error(`AES encrypt: unsupported key length ${key.length} (expected 16, 24, or 32 bytes)`);
|
|
124
|
+
}
|
|
125
|
+
const cipher = crypto.createCipheriv(algo, key, iv);
|
|
126
|
+
const enc = Buffer.concat([cipher.update(Buffer.from(data, "utf8")), cipher.final()]);
|
|
127
|
+
return enc.toString("base64");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string} encryptedData
|
|
132
|
+
* @param {import('crypto').KeyObject} privateKey
|
|
133
|
+
* @param {string} [debugLabel] — only used when {@code CYY_ENCRYPT_DEBUG} is set
|
|
134
|
+
*/
|
|
135
|
+
function rsaDecryptToUtf8OrEmpty(encryptedData, privateKey, debugLabel) {
|
|
136
|
+
const dbg = process.env.CYY_ENCRYPT_DEBUG === "1" || process.env.CYY_ENCRYPT_DEBUG === "true";
|
|
137
|
+
let raw;
|
|
138
|
+
try {
|
|
139
|
+
raw = Buffer.from(normalizeBase64(encryptedData), "base64");
|
|
140
|
+
} catch {
|
|
141
|
+
if (dbg) {
|
|
142
|
+
console.error(
|
|
143
|
+
`[cyy encrypt debug] RSA base64 decode failed for ${debugLabel || "ciphertext"} (invalid Base64?)`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
const modulusBytes =
|
|
149
|
+
privateKey?.asymmetricKeyDetails?.modulusLength != null
|
|
150
|
+
? privateKey.asymmetricKeyDetails.modulusLength / 8
|
|
151
|
+
: null;
|
|
152
|
+
if (modulusBytes != null && raw.length !== modulusBytes) {
|
|
153
|
+
if (dbg) {
|
|
154
|
+
console.error(
|
|
155
|
+
`[cyy encrypt debug] RSA ciphertext length mismatch for ${debugLabel || "field"}: got ${raw.length} bytes, private key expects ${modulusBytes} (2048-bit RSA -> 256). Often caused by truncated encrypteParam / encrypteParamExt (logs, terminal width).`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const buf = crypto.privateDecrypt(
|
|
162
|
+
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
|
|
163
|
+
raw,
|
|
164
|
+
);
|
|
165
|
+
return buf.toString("utf8");
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (dbg) {
|
|
168
|
+
const err = /** @type {Error} */ (e);
|
|
169
|
+
console.error(
|
|
170
|
+
`[cyy encrypt debug] RSA privateDecrypt failed for ${debugLabel || "field"}: ${err.message}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} encryptedData
|
|
179
|
+
* @param {string} keyBase64
|
|
180
|
+
* @param {string} ivBase64
|
|
181
|
+
*/
|
|
182
|
+
function aesDecryptWithBase64KeyOrEmpty(encryptedData, keyBase64, ivBase64) {
|
|
183
|
+
try {
|
|
184
|
+
const key = Buffer.from(normalizeBase64(keyBase64), "base64");
|
|
185
|
+
const iv = Buffer.from(normalizeBase64(ivBase64), "base64");
|
|
186
|
+
const algo = aesCbcAlgorithmForKeyLength(key.length);
|
|
187
|
+
if (!algo) return "";
|
|
188
|
+
const data = Buffer.from(normalizeBase64(encryptedData), "base64");
|
|
189
|
+
const decipher = crypto.createDecipheriv(algo, key, iv);
|
|
190
|
+
const dec = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
191
|
+
return dec.toString("utf8");
|
|
192
|
+
} catch {
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} data
|
|
199
|
+
* @param {import('crypto').KeyObject} publicKey
|
|
200
|
+
*/
|
|
201
|
+
function rsaEncrypt(data, publicKey) {
|
|
202
|
+
const buf = crypto.publicEncrypt(
|
|
203
|
+
{ key: publicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
|
|
204
|
+
Buffer.from(data, "utf8"),
|
|
205
|
+
);
|
|
206
|
+
return buf.toString("base64");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @typedef {{ encrypteData: string, encrypteParam: string, encrypteParamExt: string }} HybridEncryptResult
|
|
211
|
+
* Mirrors {@code CyyEncryptUtil.Companion.HybridEncryptResult}.
|
|
212
|
+
*/
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* From a parsed JSON root object, build a {@code HybridEncryptResult} the same way as
|
|
216
|
+
* {@code CyyEncryptUtil.hybridEncryptResultFromJson}: read Gson-mapped fields then recombine
|
|
217
|
+
* (Kotlin lines: `val encryptedData = fromJson.encrypteData`, …, `HybridEncryptResult(...)`).
|
|
218
|
+
* Extra keys (e.g. `traceId`) are ignored because we only read the three properties.
|
|
219
|
+
* @param {unknown} parsed — result of {@code JSON.parse} on the response body
|
|
220
|
+
* @returns {HybridEncryptResult | null}
|
|
221
|
+
*/
|
|
222
|
+
function hybridEncryptResultFromParsedObject(parsed) {
|
|
223
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
224
|
+
const fromJson = /** @type {Record<string, unknown>} */ (parsed);
|
|
225
|
+
const encryptedData = fromJson.encrypteData;
|
|
226
|
+
const encryptedAesKey = fromJson.encrypteParam;
|
|
227
|
+
const encryptedIv = fromJson.encrypteParamExt;
|
|
228
|
+
if (
|
|
229
|
+
typeof encryptedData !== "string" ||
|
|
230
|
+
typeof encryptedAesKey !== "string" ||
|
|
231
|
+
typeof encryptedIv !== "string"
|
|
232
|
+
) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
encrypteData: encryptedData,
|
|
237
|
+
encrypteParam: encryptedAesKey,
|
|
238
|
+
encrypteParamExt: encryptedIv,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Same as {@code CyyEncryptUtil.hybridEncryptResultFromJson}: entire response body string → parse JSON →
|
|
244
|
+
* {@code HybridEncryptResult} or null.
|
|
245
|
+
* @param {string} json
|
|
246
|
+
* @returns {HybridEncryptResult | null}
|
|
247
|
+
*/
|
|
248
|
+
function hybridEncryptResultFromJson(json) {
|
|
249
|
+
try {
|
|
250
|
+
return hybridEncryptResultFromParsedObject(JSON.parse(json));
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Same as {@code CyyNetworkEncryptManager.decryptResponseData}: {@code HybridEncryptResult} + client private → plain String.
|
|
258
|
+
* @param {HybridEncryptResult} encryptResult
|
|
259
|
+
* @param {string} clientPrivateKeyRaw
|
|
260
|
+
*/
|
|
261
|
+
function decryptResponseData(encryptResult, clientPrivateKeyRaw) {
|
|
262
|
+
return hybridDecrypt(encryptResult, clientPrivateKeyRaw);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Same as {@code CyyNetworkEncryptManager.decryptResponseDataFromJson}.
|
|
267
|
+
* @param {string} encryptedJson
|
|
268
|
+
* @param {string} clientPrivateKeyRaw
|
|
269
|
+
*/
|
|
270
|
+
function decryptResponseDataFromJson(encryptedJson, clientPrivateKeyRaw) {
|
|
271
|
+
const encryptResult = hybridEncryptResultFromJson(encryptedJson);
|
|
272
|
+
if (encryptResult == null) {
|
|
273
|
+
return "";
|
|
274
|
+
}
|
|
275
|
+
return decryptResponseData(encryptResult, clientPrivateKeyRaw);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Same as {@code CyyEncryptUtil.hybridDecrypt}: never throws; returns "" on failure.
|
|
280
|
+
* @param {HybridEncryptResult} result
|
|
281
|
+
* @param {string} clientPrivateKeyRaw
|
|
282
|
+
*/
|
|
283
|
+
function hybridDecrypt(result, clientPrivateKeyRaw) {
|
|
284
|
+
try {
|
|
285
|
+
let privateKey;
|
|
286
|
+
try {
|
|
287
|
+
privateKey = loadPrivateKey(clientPrivateKeyRaw);
|
|
288
|
+
} catch {
|
|
289
|
+
return "";
|
|
290
|
+
}
|
|
291
|
+
const aesKey = rsaDecryptToUtf8OrEmpty(result.encrypteParam, privateKey, "encrypteParam");
|
|
292
|
+
const aesIv = rsaDecryptToUtf8OrEmpty(result.encrypteParamExt, privateKey, "encrypteParamExt");
|
|
293
|
+
|
|
294
|
+
const debugEncrypt =
|
|
295
|
+
process.env.CYY_ENCRYPT_DEBUG === "1" || process.env.CYY_ENCRYPT_DEBUG === "true";
|
|
296
|
+
if (debugEncrypt) {
|
|
297
|
+
const tag = `${String(result.encrypteParam || "").slice(0, 48)}|${String(result.encrypteParamExt || "").slice(0, 48)}`;
|
|
298
|
+
if (tag !== lastHybridDebugRsaCiphertextTag) {
|
|
299
|
+
lastHybridDebugRsaCiphertextTag = tag;
|
|
300
|
+
if (!aesKey || !aesIv) {
|
|
301
|
+
console.error(
|
|
302
|
+
"[cyy encrypt debug] RSA PKCS#1 decrypt of encrypteParam / encrypteParamExt failed or yielded empty UTF-8 (wrong bundled client private key or ciphertext)",
|
|
303
|
+
);
|
|
304
|
+
} else {
|
|
305
|
+
console.error(
|
|
306
|
+
"[cyy encrypt debug] RSA decrypted encrypteParam (UTF-8, AES key material, same as App after rsaDecrypt): " +
|
|
307
|
+
JSON.stringify(aesKey),
|
|
308
|
+
);
|
|
309
|
+
console.error(
|
|
310
|
+
"[cyy encrypt debug] RSA decrypted encrypteParamExt (UTF-8, AES IV material, same as App after rsaDecrypt): " +
|
|
311
|
+
JSON.stringify(aesIv),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!aesKey || !aesIv) return "";
|
|
317
|
+
const decryptedData = aesDecryptWithBase64KeyOrEmpty(result.encrypteData, aesKey, aesIv);
|
|
318
|
+
return decryptedData || "";
|
|
319
|
+
} catch {
|
|
320
|
+
return "";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Same as {@code CyyEncryptInterceptor.decryptResponse} for the body string:
|
|
326
|
+
* `val decryptedContent = encryptManager.decryptResponseDataFromJson(encryptedJson)`;
|
|
327
|
+
* `if (decryptedContent.isEmpty())` return original `encryptedJson`, else return decrypted UTF-8 string.
|
|
328
|
+
* @param {string} encryptedJson — {@code originalBody.string()} (UTF-8)
|
|
329
|
+
* @param {string} [clientPrivateKeyRaw]
|
|
330
|
+
*/
|
|
331
|
+
function decryptResponse(encryptedJson, clientPrivateKeyRaw) {
|
|
332
|
+
if (!clientPrivateKeyRaw || !encryptedJson) return encryptedJson;
|
|
333
|
+
const body = String(encryptedJson).replace(/^\uFEFF/, "").trim();
|
|
334
|
+
const decryptedContent = decryptResponseDataFromJson(body, clientPrivateKeyRaw);
|
|
335
|
+
if (decryptedContent.length > 0) return decryptedContent;
|
|
336
|
+
return body;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function tryDecryptResponseBody(text, clientPrivateKeyRaw) {
|
|
340
|
+
return decryptResponse(text, clientPrivateKeyRaw);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Hybrid-encrypt a JSON object string (same as Kotlin {@code JSONObject(data).put(timestamp).put(nonce)}).
|
|
345
|
+
* @param {string} jsonObjectString — e.g. '{"phone":"1","objectCode":"3"}'
|
|
346
|
+
* @param {string} serverPublicKeyRaw — Base64 DER SPKI or PEM
|
|
347
|
+
*/
|
|
348
|
+
function hybridEncrypt(jsonObjectString, serverPublicKeyRaw) {
|
|
349
|
+
const serverPk = loadPublicKey(serverPublicKeyRaw);
|
|
350
|
+
const obj = JSON.parse(jsonObjectString);
|
|
351
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
352
|
+
throw new Error("hybridEncrypt expects a JSON object string");
|
|
353
|
+
}
|
|
354
|
+
obj.timestamp = Date.now();
|
|
355
|
+
obj.nonce = generateAesKey();
|
|
356
|
+
const merged = JSON.stringify(obj);
|
|
357
|
+
|
|
358
|
+
const aesKey = generateAesKey();
|
|
359
|
+
const aesIv = generateAesIv();
|
|
360
|
+
const encryptedData = aesEncryptWithBase64Key(merged, aesKey, aesIv);
|
|
361
|
+
const encryptedAesKey = rsaEncrypt(aesKey, serverPk);
|
|
362
|
+
const encryptedIv = rsaEncrypt(aesIv, serverPk);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
encrypteData: encryptedData,
|
|
366
|
+
encrypteParam: encryptedAesKey,
|
|
367
|
+
encrypteParamExt: encryptedIv,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @param {{ encrypteData: string, encrypteParam: string, encrypteParamExt: string }} result
|
|
373
|
+
*/
|
|
374
|
+
function hybridEncryptResultToJson(result) {
|
|
375
|
+
return JSON.stringify({
|
|
376
|
+
encrypteData: result.encrypteData,
|
|
377
|
+
encrypteParam: result.encrypteParam,
|
|
378
|
+
encrypteParamExt: result.encrypteParamExt,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* If `json` is still a root-level hybrid envelope (`encrypteData` / `encrypteParam` / `encrypteParamExt`),
|
|
384
|
+
* run {@code decryptResponseDataFromJson} → {@code hybridDecrypt} and return the parsed plaintext object.
|
|
385
|
+
* @param {unknown} json
|
|
386
|
+
* @param {string} [clientPrivateKeyRaw]
|
|
387
|
+
* @returns {unknown}
|
|
388
|
+
*/
|
|
389
|
+
function parsedJsonAfterHybridEnvelopeDecrypt(json, clientPrivateKeyRaw) {
|
|
390
|
+
if (!clientPrivateKeyRaw || json == null || typeof json !== "object" || Array.isArray(json)) {
|
|
391
|
+
return json;
|
|
392
|
+
}
|
|
393
|
+
if (Object.prototype.hasOwnProperty.call(json, "_raw")) return json;
|
|
394
|
+
if (!hybridEncryptResultFromParsedObject(json)) return json;
|
|
395
|
+
const plain = decryptResponseDataFromJson(JSON.stringify(json), clientPrivateKeyRaw);
|
|
396
|
+
if (!plain || !plain.trim()) return json;
|
|
397
|
+
try {
|
|
398
|
+
return JSON.parse(plain);
|
|
399
|
+
} catch {
|
|
400
|
+
return json;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @param {unknown} json — parsed JSON object
|
|
406
|
+
*/
|
|
407
|
+
function looksLikeHybridEnvelope(json) {
|
|
408
|
+
return hybridEncryptResultFromParsedObject(json) != null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function encryptEnvConfigured() {
|
|
412
|
+
if (encryptDisabledByEnv()) return false;
|
|
413
|
+
const { serverPublicKey, clientPrivateKey } = resolveEncryptKeys();
|
|
414
|
+
return !!(serverPublicKey && clientPrivateKey);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = {
|
|
418
|
+
hybridEncrypt,
|
|
419
|
+
hybridEncryptResultToJson,
|
|
420
|
+
hybridDecrypt,
|
|
421
|
+
hybridEncryptResultFromJson,
|
|
422
|
+
hybridEncryptResultFromParsedObject,
|
|
423
|
+
decryptResponseDataFromJson,
|
|
424
|
+
decryptResponseData,
|
|
425
|
+
decryptResponse,
|
|
426
|
+
tryDecryptResponseBody,
|
|
427
|
+
encryptEnvConfigured,
|
|
428
|
+
encryptDisabledByEnv,
|
|
429
|
+
resolveEncryptKeys,
|
|
430
|
+
loadPublicKey,
|
|
431
|
+
loadPrivateKey,
|
|
432
|
+
parsedJsonAfterHybridEnvelopeDecrypt,
|
|
433
|
+
looksLikeHybridEnvelope,
|
|
434
|
+
};
|
package/src/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const config = require("./config");
|
|
4
|
+
const encrypt = require("./encrypt");
|
|
4
5
|
|
|
5
6
|
const DEFAULT_VERSION =
|
|
6
7
|
process.env.CYY_BOOTSTRAP_VERSION_CODE || "22118";
|
|
@@ -55,22 +56,48 @@ function buildDefaultHeaders() {
|
|
|
55
56
|
* @param {string} options.method
|
|
56
57
|
* @param {Record<string,string>} [options.headers]
|
|
57
58
|
* @param {string|null} [options.body]
|
|
59
|
+
* @param {boolean} [options.decryptHybridResponse] — when true, treat the response body as optional hybrid envelope (same as App `CyyEncryptInterceptor` after an `encrypte: true` request). Ignored unless {@code decryptResponsePrivateKey} is set.
|
|
60
|
+
* @param {string} [options.decryptResponsePrivateKey] — client RSA private key (PEM or Base64 PKCS#8)
|
|
61
|
+
* @param {boolean} [options.includeRawWireText] — when true, also return {@code rawWireText}: response body after BOM/trim, **before** optional hybrid decrypt (so callers can run their own decrypt-then-parse pipeline).
|
|
58
62
|
*/
|
|
59
|
-
async function request(
|
|
63
|
+
async function request(
|
|
64
|
+
url,
|
|
65
|
+
{
|
|
66
|
+
method,
|
|
67
|
+
headers,
|
|
68
|
+
body,
|
|
69
|
+
decryptHybridResponse = false,
|
|
70
|
+
decryptResponsePrivateKey,
|
|
71
|
+
includeRawWireText = false,
|
|
72
|
+
},
|
|
73
|
+
) {
|
|
60
74
|
const res = await fetch(url, {
|
|
61
75
|
method,
|
|
62
76
|
headers,
|
|
63
77
|
body: body ?? undefined,
|
|
64
78
|
redirect: "follow",
|
|
65
79
|
});
|
|
66
|
-
|
|
80
|
+
let text = await res.text();
|
|
81
|
+
text = text.replace(/^\uFEFF/, "").trimStart();
|
|
82
|
+
const rawWireText = text;
|
|
83
|
+
const doHybridResponseDecrypt = decryptHybridResponse && !!decryptResponsePrivateKey;
|
|
84
|
+
if (doHybridResponseDecrypt) {
|
|
85
|
+
text = encrypt.decryptResponse(text, decryptResponsePrivateKey);
|
|
86
|
+
}
|
|
67
87
|
let json = null;
|
|
68
88
|
try {
|
|
69
89
|
json = text ? JSON.parse(text) : null;
|
|
70
90
|
} catch {
|
|
71
91
|
json = { _raw: text };
|
|
72
92
|
}
|
|
73
|
-
|
|
93
|
+
if (doHybridResponseDecrypt && json != null && typeof json === "object") {
|
|
94
|
+
json = encrypt.parsedJsonAfterHybridEnvelopeDecrypt(json, decryptResponsePrivateKey);
|
|
95
|
+
}
|
|
96
|
+
const out = { ok: res.ok, status: res.status, json };
|
|
97
|
+
if (includeRawWireText) {
|
|
98
|
+
out.rawWireText = rawWireText;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
function moduleUrl(moduleKey, pathSuffix) {
|