diff --git a/Dockerfile b/Dockerfile index dae72b66..debb54cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use base image to build the project avoid npm install every time -FROM jumpserver/luna-base:20250609_105214 AS stage-build +FROM jumpserver/luna-base:20260331_081054 AS stage-build ARG VERSION ENV VERSION=$VERSION diff --git a/package.json b/package.json index 104bc095..98d8fd1e 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "popper.js": "^1.16.1", "requirejs": "^2.3.6", "rxjs": "^7.8.1", + "sm-crypto": "^0.4.0", "tslib": "^2.6.2", "xterm-theme": "^1.1.0", "zone.js": "~0.15.0" diff --git a/src/app/utils/crypto.ts b/src/app/utils/crypto.ts index b4927de2..6841f909 100644 --- a/src/app/utils/crypto.ts +++ b/src/app/utils/crypto.ts @@ -1,10 +1,10 @@ import * as CryptoJS from 'crypto-js'; -import {getCsrfTokenFromCookie, getCookie} from '@app/utils/common'; -import {Buffer} from 'buffer'; -import {JSEncrypt} from 'jsencrypt'; +import { getCookie } from '@app/utils/common'; +import { Buffer } from 'buffer'; +import { JSEncrypt } from 'jsencrypt'; +import { sm2, sm4 } from 'sm-crypto'; - -export function fillKey(key: string): Buffer|string { +export function fillKey(key: string): Buffer | string { const KeyLength = 16; if (key.length > KeyLength) { key = key.slice(0, KeyLength); @@ -17,8 +17,7 @@ export function fillKey(key: string): Buffer|string { return filledKey; } - -export function aesEncrypt(text: string, originKey: string): string { +function aesEncrypt(text, originKey) { const key = CryptoJS.enc.Utf8.parse(fillKey(originKey)); return CryptoJS.AES.encrypt(text, key, { mode: CryptoJS.mode.ECB, @@ -26,49 +25,130 @@ export function aesEncrypt(text: string, originKey: string): string { }).toString(); } +function rsaEncrypt(text, pubKey) { + if (!text) { + return text; + } + const jsEncrypt = new JSEncrypt(); + jsEncrypt.setPublicKey(pubKey); + return jsEncrypt.encrypt(text); +} -export function aesDecrypt(cipherText: string, originKey: string): string { - const key = CryptoJS.enc.Utf8.parse(fillKey(originKey)); - const bytes = CryptoJS.AES.decrypt(cipherText, key, { - mode: CryptoJS.mode.ECB, - padding: CryptoJS.pad.ZeroPadding - }); - return CryptoJS.enc.Utf8.stringify(bytes); +function rsaDecrypt(cipher, pkey) { + const jsEncrypt = new JSEncrypt(); + jsEncrypt.setPrivateKey(pkey); + return jsEncrypt.decrypt(cipher); } -export function aesEncryptByCsrf(text: string): string { - const key = getCsrfTokenFromCookie(); - if (!key) { console.log('Not found csrf connectToken'); } - return aesEncrypt(text, key); +function hexToBytes(hex) { + if (!hex) return new Uint8Array([]); + hex = hex.toString().trim().toLowerCase(); + if (hex.startsWith('0x')) { + hex = hex.slice(2); + } + // 确保是偶数长度 + const len = Math.floor(hex.length / 2); + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return bytes; } -export function aesDecryptByCsrf(cipherText: string): string { - const key = getCsrfTokenFromCookie(); - if (!key) { console.log('Not found csrf connectToken'); } - return aesDecrypt(cipherText, key); +function bytesToBase64(bytes) { + // Uint8Array -> base64(标准 base64) + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); } -export function rsaEncrypt(text, pubKey) { - const jsEncrypt = new JSEncrypt(); - jsEncrypt.setPublicKey(pubKey); - return jsEncrypt.encrypt(text); +function rsaEncryptPassword(password, rsaPublicKey) { + const aesKey = (Math.random() + 1).toString(36).substring(2); + // public key 是 base64 存储的 + const keyCipher = rsaEncrypt(aesKey, rsaPublicKey); + const passwordCipher = aesEncrypt(password, aesKey); + return `${keyCipher}:${passwordCipher}`; +} + +function ensureSm2PublicKey(sm2PublicKey) { + // sm2.min.js 的 doEncrypt 需要能被 decodePointHex 解析的公钥: + // 通常为非压缩点 hex,格式 `04||x||y`(总长度 130)。 + // 但后端生成/下发的公钥有时是 `x||y`(长度 128),这里做归一化补齐 `04` 前缀。 + if (typeof sm2PublicKey === 'string') { + sm2PublicKey = sm2PublicKey.replace(/"/g, '').trim(); + if (sm2PublicKey.startsWith('0x')) { + sm2PublicKey = sm2PublicKey.slice(2); + } + // 后端下发的 SM2 公钥常见是 x||y(128 hex),sm-crypto 需要 04||x||y(130 hex) + if (sm2PublicKey.length === 128 && !sm2PublicKey.startsWith('04')) { + sm2PublicKey = '04' + sm2PublicKey; + } + } + return sm2PublicKey; +} + +function gmEncryptPassword(password, sm2PublicKey) { + sm2PublicKey = ensureSm2PublicKey(sm2PublicKey); + // 只适配前端,不改后端: + // 直接生成 16 字符 key(后端 padding_key 会保持原样,不再补齐) + const sm4KeyRaw = randomString(16); + const sm4KeyHex = Buffer.from(sm4KeyRaw).toString('hex'); + + let keyCipher = ''; + try { + // 与后端 gmssl.sm2.CryptSM2 默认 decrypt 的 mode 对齐: + // gmssl 解析的格式是 C1C2C3(mode=0),前端这里输出也用 mode=0。 + keyCipher = sm2.doEncrypt(sm4KeyRaw, sm2PublicKey, 0); + } catch (e) { + console.error('gmEncryptPassword sm2.doEncrypt failed:', e); + // 避免前端崩溃:失败时返回明文,由后端按原值流程处理(至少可继续登录/看报错) + return password; + } + + const passwordCipher = sm4.encrypt(password, sm4KeyHex); + // sm2/sm4 默认输出是 hex,但后端 gm.py/session.py 需要 base64: + // - sm2_decrypt: base64.b64decode + // - sm4 decrypt: base64.urlsafe_b64decode + const keyCipherB64 = bytesToBase64(hexToBytes(keyCipher)); + const passwordCipherB64 = bytesToBase64(hexToBytes(passwordCipher)); + return `${keyCipherB64}:${passwordCipherB64}`; } export function encryptPassword(password) { if (!password) { + console.log('password is empty'); return ''; } - const aesKey = (Math.random() + 1).toString(36).substring(2); - // public key 是 base64 存储的 - let rsaPublicKeyText = getCookie('jms_public_key'); - if (!rsaPublicKeyText) { + let publicKeyText = getCookie('jms_public_key'); + if (!publicKeyText) { + console.log('publicKeyText is empty'); return password; } - rsaPublicKeyText = rsaPublicKeyText - .replace('"', '') - .replace('"', ''); - const rsaPublicKey = atob(rsaPublicKeyText); - const keyCipher = rsaEncrypt(aesKey, rsaPublicKey); - const passwordCipher = aesEncrypt(password, aesKey); - return `${keyCipher}:${passwordCipher}`; + publicKeyText = publicKeyText.replace(/"/g, ''); + publicKeyText = atob(publicKeyText); + let cipher = ''; + let jmsGMSSL = getCookie('jms_gm_ssl'); + if (publicKeyText.includes('PUBLIC KEY')) { + jmsGMSSL = '0'; + } + if (jmsGMSSL === '1') { + cipher = gmEncryptPassword(password, publicKeyText); + } else { + cipher = rsaEncryptPassword(password, publicKeyText); + } + + return cipher; +} + +export function randomString(length) { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + + return result; } diff --git a/yarn.lock b/yarn.lock index fbcda9dc..b188e631 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6296,7 +6296,7 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -jsbn@1.1.0: +jsbn@1.1.0, jsbn@^1.1.0: version "1.1.0" resolved "https://registry.npmmirror.com/jsbn/-/jsbn-1.1.0.tgz" integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== @@ -8418,6 +8418,13 @@ slice-ansi@^7.1.0: ansi-styles "^6.2.1" is-fullwidth-code-point "^5.0.0" +sm-crypto@^0.4.0: + version "0.4.0" + resolved "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.4.0.tgz#13917f5b41e8428d764e9e6934840fdede6a4022" + integrity sha512-OexH2V1EqmhXuOIPGoCl55OjMF0wwPUM/zhUjT0Q6vHBeopSRvTNRy76/1eRoFs3VBKt39hdFnxwpFmooHYa2A== + dependencies: + jsbn "^1.1.0" + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz"