diff --git a/package-lock.json b/package-lock.json index 7f5737f..efdf609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@iconify/vue": "^5.0.0", "@internationalized/date": "^3.12.0", "@internationalized/number": "^3.6.5", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.2.1", "ag-grid-enterprise": "^35.1.0", @@ -273,6 +275,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/runtime": { "version": "0.114.0", "dev": true, diff --git a/package.json b/package.json index 7d0ea41..0c6ca6b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "@iconify/vue": "^5.0.0", "@internationalized/date": "^3.12.0", "@internationalized/number": "^3.6.5", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.2.1", "ag-grid-enterprise": "^35.1.0", diff --git a/src/features/ht/components/Ht.vue b/src/features/ht/components/Ht.vue index ffbe398..61ed8e5 100644 --- a/src/features/ht/components/Ht.vue +++ b/src/features/ht/components/Ht.vue @@ -719,6 +719,8 @@ const importContractSegments = async (event: Event) => { showMessageDialog(t('ht.importFailedTitle'), t('ht.importCurrentIndustryMissing')) } else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') { showMessageDialog(t('ht.importFailedTitle'), t('ht.importPackageIndustryMissing')) + } else if (message === 'ZW_CRYPTO_UNAVAILABLE') { + showMessageDialog(t('ht.importFailedTitle'), t('ht.importCryptoUnavailable')) } else { showMessageDialog(t('ht.importFailedTitle'), t('ht.importFileInvalid')) } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 685155e..7a17e35 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -180,6 +180,7 @@ export const enUS = { importProjectIdMissing: 'This package does not contain project ID (legacy export). Import is blocked to avoid cross-project overwrite.', importProjectMismatch: 'This package belongs to another project and cannot override current project.', importInvalidFile: 'File is invalid, corrupted, or modified.', + importCryptoUnavailable: 'This runtime does not support secure archive decryption. Please open the site over HTTPS or another secure local context and try again.', importWriteError: 'An error occurred while writing local data.', openFile: 'Open file' } @@ -259,6 +260,7 @@ export const enUS = { importCurrentIndustryMissing: 'Current project industry is not set. Please set it in "Basic Info" first.', importPackageIndustryMissing: 'Import package missing industry info. Re-export with latest version and try again.', importFileInvalid: 'Invalid or corrupted file, or not a contract-segment package.', + importCryptoUnavailable: 'This runtime does not support secure archive decryption. Please open the site over HTTPS or another secure local context and try again.', deleteSingleTitle: 'Confirm Delete Segment', deleteSingleDesc: 'Delete "{name}" and all related service/pricing data. Continue?', deleteBatchTitle: 'Confirm Batch Delete', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b0f4f13..fab738e 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -180,6 +180,7 @@ export const zhCN = { importProjectIdMissing: '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。', importProjectMismatch: '该数据包属于其他项目,不能覆盖当前项目。', importInvalidFile: '文件无效、已损坏或被修改。', + importCryptoUnavailable: '当前运行环境不支持安全解密导入,请使用 HTTPS 域名或本地安全环境访问后重试。', importWriteError: '写入本地数据时发生错误。', openFile: '打开文件' } @@ -259,6 +260,7 @@ export const zhCN = { importCurrentIndustryMissing: '当前项目未设置工程行业,请先在“基础信息”里新建项目。', importPackageIndustryMissing: '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。', importFileInvalid: '文件无效、已损坏或不是合同段导出文件。', + importCryptoUnavailable: '当前运行环境不支持安全解密导入,请使用 HTTPS 域名或本地安全环境访问后重试。', deleteSingleTitle: '确认删除合同段', deleteSingleDesc: '即将删除“{name}”及其关联咨询服务和计价数据,是否继续?', deleteBatchTitle: '确认批量删除', diff --git a/src/layout/tab.vue b/src/layout/tab.vue index ae21013..f8037f2 100644 --- a/src/layout/tab.vue +++ b/src/layout/tab.vue @@ -1519,6 +1519,14 @@ const triggerImport = () => { importFileRef.value?.click() } +const resolveProjectImportErrorMessage = (error: unknown) => { + const message = error instanceof Error ? error.message : '' + if (message === 'ZW_CRYPTO_UNAVAILABLE') { + return t('tab.messages.importCryptoUnavailable') + } + return t('tab.messages.importInvalidFile') +} + const prepareImportPayloadFromFile = async ( file: File, options?: { skipConfirm?: boolean } @@ -1549,7 +1557,7 @@ const importData = async (event: Event) => { await prepareImportPayloadFromFile(file) } catch (error) { console.error('import failed:', error) - showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile')) + showMessageDialog(t('tab.messages.importFailedTitle'), resolveProjectImportErrorMessage(error)) } finally { input.value = '' } @@ -1747,7 +1755,7 @@ onMounted(() => { if (!pendingHomeImportFile) return await prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => { console.error('home import failed:', error) - showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile')) + showMessageDialog(t('tab.messages.importFailedTitle'), resolveProjectImportErrorMessage(error)) }) })() void (async () => { diff --git a/src/lib/zwArchive.ts b/src/lib/zwArchive.ts index 9d86149..4a212e7 100644 --- a/src/lib/zwArchive.ts +++ b/src/lib/zwArchive.ts @@ -1,3 +1,6 @@ +import { gcm } from '@noble/ciphers/aes.js' +import { sha256 } from '@noble/hashes/sha2.js' + const ZW_VERSION = 1 const KEY_SEED = 'JGJS2026::ZW::ARCHIVE::V1::DO_NOT_TAMPER' const MAGIC_BYTES = new Uint8Array([0x4a, 0x47, 0x4a, 0x53, 0x5a, 0x57]) // JGJSZW @@ -6,32 +9,59 @@ export const ZW_FILE_EXTENSION = '.zw' const encoder = new TextEncoder() const decoder = new TextDecoder() -let cachedKeyPromise: Promise | null = null +let cachedKeyBytes: Uint8Array | null = null const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer -const getArchiveKey = async (): Promise => { - if (!cachedKeyPromise) { - cachedKeyPromise = (async () => { - const hash = await crypto.subtle.digest('SHA-256', encoder.encode(KEY_SEED)) - return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) - })() +const getCryptoApi = () => { + const cryptoApi = globalThis.crypto + if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') { + throw new Error('ZW_CRYPTO_UNAVAILABLE') } - return cachedKeyPromise + return cryptoApi +} + +const hasSubtleCrypto = () => { + const subtle = globalThis.crypto?.subtle + return Boolean( + subtle && + typeof subtle.importKey === 'function' && + typeof subtle.encrypt === 'function' && + typeof subtle.decrypt === 'function' + ) +} + +const getArchiveKeyBytes = () => { + if (!cachedKeyBytes) { + cachedKeyBytes = sha256(encoder.encode(KEY_SEED)) + } + return cachedKeyBytes +} + +const getArchiveWebCryptoKey = async () => { + if (!hasSubtleCrypto()) throw new Error('ZW_SUBTLE_UNAVAILABLE') + return getCryptoApi().subtle.importKey( + 'raw', + toArrayBuffer(getArchiveKeyBytes()), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) } export const encodeZwArchive = async (payload: unknown): Promise => { - const key = await getArchiveKey() - const iv = crypto.getRandomValues(new Uint8Array(12)) + const cryptoApi = getCryptoApi() + const iv = cryptoApi.getRandomValues(new Uint8Array(12)) const ivBuffer = toArrayBuffer(iv) const plainText = encoder.encode(JSON.stringify(payload)) - const cipherBuffer = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv: ivBuffer }, - key, - toArrayBuffer(plainText) - ) - const cipher = new Uint8Array(cipherBuffer) + const cipher = hasSubtleCrypto() + ? new Uint8Array(await cryptoApi.subtle.encrypt( + { name: 'AES-GCM', iv: ivBuffer }, + await getArchiveWebCryptoKey(), + toArrayBuffer(plainText) + )) + : gcm(getArchiveKeyBytes(), iv).encrypt(plainText) const result = new Uint8Array(MAGIC_BYTES.length + 1 + 1 + iv.length + cipher.length) let offset = 0 result.set(MAGIC_BYTES, offset) @@ -80,13 +110,17 @@ export const decodeZwArchive = async (raw: ArrayBuffer | Uint8Array): Promise throw new Error('INVALID_ZW_PAYLOAD') } - const key = await getArchiveKey() - const ivBuffer = toArrayBuffer(iv) - const cipherBuffer = toArrayBuffer(cipher) - let plainBuffer: ArrayBuffer try { - plainBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, cipherBuffer) + if (hasSubtleCrypto()) { + plainBuffer = await getCryptoApi().subtle.decrypt( + { name: 'AES-GCM', iv: toArrayBuffer(iv) }, + await getArchiveWebCryptoKey(), + toArrayBuffer(cipher) + ) + } else { + plainBuffer = toArrayBuffer(gcm(getArchiveKeyBytes(), iv).decrypt(cipher)) + } } catch { throw new Error('INVALID_ZW_TAMPERED') }