diff --git a/jest.config.browser.mjs b/jest.config.browser.mjs index bbf1b3b..a02a9f2 100644 --- a/jest.config.browser.mjs +++ b/jest.config.browser.mjs @@ -4,7 +4,8 @@ export default { testMatch: [ "/test/Browser*.spec.mjs", "/test/Http*.spec.mjs", - "/test/downloadFunctions.spec.mjs" + "/test/downloadFunctions.spec.mjs", + "/test/progress.spec.mjs" ], roots: ['/test', '/src'], diff --git a/package.json b/package.json index 57d8a39..54422d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yeez-tech/meta-encryptor", - "version": "5.0.3", + "version": "5.0.4", "description": "Data Seal/Unseal for Fidelius", "main": "build/commonjs/index.node.cjs", "module": "build/es/index.node.js", diff --git a/src/browser/README.md b/src/browser/README.md index 787c931..f30d979 100644 --- a/src/browser/README.md +++ b/src/browser/README.md @@ -47,6 +47,7 @@ npm install @yeez-tech/meta-encryptor | `filename` | string | ✅ | 下载文件名(必需,无默认值) | | `onLog` | function | ❌ | 日志回调 `(message: string) => void` | | `onProgress` | function | ❌ | 进度回调 `(total, processed, readBytes, writeBytes) => void` | +| `onDownloadReady` | function | ❌ | 下载就绪回调:HTTP 流首个数据块进入管道时触发(HEAD+tail 完成后),可用于关闭准备蒙层 | | `onSuccess` | function | ❌ | 成功回调 `(data: { filename }) => void` | | `onError` | function | ❌ | 错误回调 `(error: Error) => void` | diff --git a/src/browser/blob_download.js b/src/browser/blob_download.js index da0c5a0..a6b1d6d 100644 --- a/src/browser/blob_download.js +++ b/src/browser/blob_download.js @@ -1,13 +1,14 @@ import { Unsealer } from './Unsealer.js' import { HttpSealedFileStream } from './HttpSealedFileStream.js' +import { createProgressTransformer, createDownloadReadyTransformer } from '../common/progress.js'; /** * @param {string} url * @param {string} privateKeyHex * @param {string} filename - * @param {{ log?: Function, onProgress?: Function, fetch?: Function }} [opts] + * @param {{ log?: Function, onProgress?: Function, fetch?: Function, size?: number, onDownloadReady?: Function }} [opts] */ -export async function blobDownloadAndDecrypt(url, privateKeyHex, filename, { log, onProgress, fetch: _fetch } = {}) { +export async function blobDownloadAndDecrypt(url, privateKeyHex, filename, { log, onProgress, fetch: _fetch, size, onDownloadReady } = {}) { log = log || (() => {}) try { const chunks = [] @@ -21,7 +22,9 @@ export async function blobDownloadAndDecrypt(url, privateKeyHex, filename, { log }) await stream + .pipeThrough(createDownloadReadyTransformer(onDownloadReady)) .pipeThrough(unsealer) + .pipeThrough(createProgressTransformer(size, onProgress)) .pipeTo(new WritableStream({ write(plain) { chunks.push(new Uint8Array(plain)) diff --git a/src/browser/downloadUnsealed.js b/src/browser/downloadUnsealed.js index 910506c..9352a89 100644 --- a/src/browser/downloadUnsealed.js +++ b/src/browser/downloadUnsealed.js @@ -85,6 +85,7 @@ async function inspectSealed(url, log) { * @param {string} options.filename * @param {Function} [options.onLog] * @param {Function} [options.onProgress] - (total, processed, readBytes, writeBytes) => {} + * @param {Function} [options.onDownloadReady] - HTTP 流首个数据块进入管道时触发(可关蒙层) * @param {Function} [options.onSuccess] * @param {Function} [options.onError] * @returns {Promise} @@ -95,6 +96,7 @@ export async function downloadUnsealed({ filename, onLog, onProgress, + onDownloadReady, onSuccess, onError }) { @@ -128,7 +130,12 @@ export async function downloadUnsealed({ if (!mobile) { try { log('Trying stream download...') - await streamDownloadAndDecrypt(url, key, filename, { log, onProgress, size: meta.plaintextSize }) + await streamDownloadAndDecrypt(url, key, filename, { + log, + onProgress, + size: meta.plaintextSize, + onDownloadReady, + }) if (onSuccess) onSuccess({ filename }) return } catch (e) { @@ -137,7 +144,7 @@ export async function downloadUnsealed({ } log('Using Blob download...') - await blobDownloadAndDecrypt(url, key, filename, { log, onProgress }) + await blobDownloadAndDecrypt(url, key, filename, { log, onProgress, onDownloadReady }) if (onSuccess) onSuccess({ filename }) } catch (error) { log('Download failed: ' + error.message) diff --git a/src/browser/stream_download.js b/src/browser/stream_download.js index c740979..94eb67c 100644 --- a/src/browser/stream_download.js +++ b/src/browser/stream_download.js @@ -1,6 +1,7 @@ import { Unsealer } from './Unsealer.js' import { HttpSealedFileStream } from './HttpSealedFileStream.js' import { MetaEncryptorError } from '../common/errors.js'; +import { createProgressTransformer, createDownloadReadyTransformer } from '../common/progress.js'; async function ensureStreamSaver(log) { if (typeof window === 'undefined') return null; @@ -26,7 +27,14 @@ async function ensureStreamSaver(log) { } export async function getBestWritable(filename, { log, size } = {}) { - /* + + + const ss = await ensureStreamSaver(log); + if (ss) { + log?.('Using StreamSaver...'); + return ss.createWriteStream(filename, size !== undefined ? { size } : undefined); + } + if (typeof window !== 'undefined' && window.showSaveFilePicker) { try { log?.('Using File System Access API...'); @@ -37,18 +45,12 @@ export async function getBestWritable(filename, { log, size } = {}) { log?.(`File System Access API unavailable: ${e.message}`); } } - */ - - const ss = await ensureStreamSaver(log); - if (ss) { - log?.('Using StreamSaver...'); - return ss.createWriteStream(filename, size !== undefined ? { size } : undefined); - } + return null; } -export async function streamDownloadAndDecrypt(url, privateKeyHex, filename, { log, onProgress, writable, size, fetch: _fetch } = {}) { +export async function streamDownloadAndDecrypt(url, privateKeyHex, filename, { log, onProgress, writable, size, fetch: _fetch, onDownloadReady } = {}) { log = log || (() => {}) try { const out = writable || await getBestWritable(filename, { log, size }) @@ -64,7 +66,9 @@ export async function streamDownloadAndDecrypt(url, privateKeyHex, filename, { l }) await stream + .pipeThrough(createDownloadReadyTransformer(onDownloadReady)) .pipeThrough(unsealer) + .pipeThrough(createProgressTransformer(size, onProgress)) .pipeTo(out) log('Download complete'); diff --git a/src/common/progress.js b/src/common/progress.js new file mode 100644 index 0000000..f501738 --- /dev/null +++ b/src/common/progress.js @@ -0,0 +1,42 @@ +/** + * Create a TransformStream that reports plaintext download progress. + * Accumulates bytes from each chunk and calls onProgress. + * + * @param {number} plaintextSize - total plaintext size in bytes + * @param {Function} onProgress - progress callback (totalSize, receivedBytes) + * @returns {TransformStream} + */ +export function createProgressTransformer(plaintextSize, onProgress) { + let received = 0 + return new TransformStream({ + transform(chunk, controller) { + received += chunk.length + if (onProgress) { + onProgress(plaintextSize, received) + } + controller.enqueue(chunk) + } + }) +} + +/** + * Create a TransformStream that fires once when the first chunk flows through. + * Place after HttpSealedFileStream to signal download stream is ready (e.g. dismiss overlay). + * + * @param {Function} [onDownloadReady] + * @returns {TransformStream} + */ +export function createDownloadReadyTransformer(onDownloadReady) { + let called = false + return new TransformStream({ + transform(chunk, controller) { + if (!called) { + called = true + if (onDownloadReady) { + onDownloadReady() + } + } + controller.enqueue(chunk) + } + }) +} diff --git a/src/common/ypccrypto.common.js b/src/common/ypccrypto.common.js index 2f6ddd1..17282d1 100644 --- a/src/common/ypccrypto.common.js +++ b/src/common/ypccrypto.common.js @@ -132,14 +132,17 @@ function generateAESKeyFrom(pkey, skey){ return derived_key; } -const eth_hash_prefix = Buffer.from("\\x19Ethereum Signed Message:\\n32"); +const eth_hash_prefix = Buffer.concat([ + Buffer.from([0x19]), + Buffer.from("Ethereum Signed Message:\n32"), +]); function signMessage(skey, raw) { - let raw_hash = Buffer.from(keccak256(Buffer.from(raw))); + let raw_hash = Buffer.from(keccak256(toUint8Array(raw))); let msg = new Uint8Array(eth_hash_prefix.length + raw_hash.length) msg.set(eth_hash_prefix) msg.set(raw_hash, eth_hash_prefix.length) - msg = Buffer.from(keccak256(Buffer.from(msg))) + msg = Buffer.from(keccak256(toUint8Array(msg))) const msgBytes = toUint8Array(msg); const skeyBytes = toUint8Array(skey); diff --git a/test/BrowserCrypto.spec.mjs b/test/BrowserCrypto.spec.mjs index eadf889..6cbbddd 100644 --- a/test/BrowserCrypto.spec.mjs +++ b/test/BrowserCrypto.spec.mjs @@ -1,4 +1,5 @@ import { BrowserCrypto } from '../src/browser/ypccrypto.browser.js'; +import { eth_hash_prefix } from '../src/common/ypccrypto.common.js'; import fs from 'fs/promises'; import path from 'path'; @@ -256,6 +257,35 @@ describe('BrowserCrypto compatibility with Node YPCCrypto', () => { }); }); +describe('signMessage Ethereum prefix regression', () => { + test('eth_hash_prefix uses correct Ethereum Signed Message format', () => { + expect(eth_hash_prefix.length).toBe(28); + expect(eth_hash_prefix[0]).toBe(0x19); + expect(Buffer.from(eth_hash_prefix.subarray(1)).toString()).toBe( + 'Ethereum Signed Message:\n32' + ); + }); + + test('generateSignature matches v4.x compatible vector', () => { + const skey = Buffer.from( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'hex' + ); + const epkey = Buffer.from( + '2acc91b839b115519fa43b7a0bcf20f7e83ef983c00e854343025a5a26a08a3e2b1bbb2c178989126d09d7a7def36fd029eafd60bdfe046b12b226877e018d92', + 'hex' + ); + const ehash = Buffer.from( + '30890aacb90c4a6440023df1a51a74050756d04811ce0a47eea86dd47dec320a', + 'hex' + ); + const sig = BrowserCrypto.generateSignature(skey, epkey, ehash); + expect(Buffer.from(sig).toString('hex')).toBe( + '568643ef491fb8e32d061783a6ac94aacdf7967b39ef53490121c39d7997e043630af675554b5538db2b2bab96068351842490a1ee7551d13417fd1052e41cf61c' + ); + }); +}); + // --- Fixtures-based consistency checks --- function hexToBytes(hex){ if (hex.startsWith('0x')) hex = hex.slice(2); diff --git a/test/HttpSealedFileStream.spec.mjs b/test/HttpSealedFileStream.spec.mjs index d656bb0..4b1042e 100644 --- a/test/HttpSealedFileStream.spec.mjs +++ b/test/HttpSealedFileStream.spec.mjs @@ -13,6 +13,7 @@ import { HeaderSize, BlockInfoSize } from '../src/common/limits.js'; import { BrowserCrypto } from '../src/browser/ypccrypto.browser.js'; import { Unsealer } from '../src/browser/Unsealer.js'; import { HttpSealedFileStream } from '../src/browser/HttpSealedFileStream.js'; +import { createDownloadReadyTransformer } from '../src/common/progress.js'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; @@ -117,6 +118,26 @@ describe('HttpSealedFileStream', () => { expect(chunks[0].length).toBe(64); }); + test('createDownloadReadyTransformer fires on first chunk from HttpSealedFileStream', async () => { + const original = Buffer.alloc(100, 'A'); + const { diskBuf } = sealBuffer(original); + let ready = false; + + const fetch = createMockFetch(diskBuf); + const hsfs = new HttpSealedFileStream('http://x', { + chunkSize: 4096, + fetch, + }).pipeThrough(createDownloadReadyTransformer(() => { ready = true; })); + + const reader = hsfs.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + expect(ready).toBe(true); + }); + test('streams content in multiple chunks with small chunkSize', async () => { const original = Buffer.alloc(10000, 'Z'); const { diskBuf } = sealBuffer(original); diff --git a/test/progress.spec.mjs b/test/progress.spec.mjs new file mode 100644 index 0000000..0eaf187 --- /dev/null +++ b/test/progress.spec.mjs @@ -0,0 +1,55 @@ +import { createProgressTransformer, createDownloadReadyTransformer } from '../src/common/progress.js'; + +describe('progress transformers', () => { + test('createProgressTransformer reports cumulative bytes', async () => { + const calls = []; + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + writer.write(new Uint8Array(3)); + writer.write(new Uint8Array(2)); + writer.close(); + + const reader = readable + .pipeThrough(createProgressTransformer(10, (total, received) => { + calls.push([total, received]); + })) + .getReader(); + + while (!(await reader.read()).done) {} + + expect(calls).toEqual([[10, 3], [10, 5]]); + }); + + test('createDownloadReadyTransformer fires once on first chunk', async () => { + let readyCount = 0; + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + writer.write(new Uint8Array(64)); + writer.write(new Uint8Array(8)); + writer.close(); + + const reader = readable + .pipeThrough(createDownloadReadyTransformer(() => { readyCount++; })) + .getReader(); + + await reader.read(); + expect(readyCount).toBe(1); + await reader.read(); + expect(readyCount).toBe(1); + }); + + test('createDownloadReadyTransformer no-ops when callback omitted', async () => { + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + writer.write(new Uint8Array(1)); + writer.close(); + + const reader = readable + .pipeThrough(createDownloadReadyTransformer()) + .getReader(); + + const { value, done } = await reader.read(); + expect(done).toBe(false); + expect(value.length).toBe(1); + }); +});