Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion jest.config.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export default {
testMatch: [
"<rootDir>/test/Browser*.spec.mjs",
"<rootDir>/test/Http*.spec.mjs",
"<rootDir>/test/downloadFunctions.spec.mjs"
"<rootDir>/test/downloadFunctions.spec.mjs",
"<rootDir>/test/progress.spec.mjs"
],

roots: ['<rootDir>/test', '<rootDir>/src'],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
7 changes: 5 additions & 2 deletions src/browser/blob_download.js
Original file line number Diff line number Diff line change
@@ -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 = []
Expand All @@ -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))
Expand Down
11 changes: 9 additions & 2 deletions src/browser/downloadUnsealed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
Expand All @@ -95,6 +96,7 @@ export async function downloadUnsealed({
filename,
onLog,
onProgress,
onDownloadReady,
onSuccess,
onError
}) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
22 changes: 13 additions & 9 deletions src/browser/stream_download.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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...');
Expand All @@ -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 })
Expand All @@ -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');
Expand Down
42 changes: 42 additions & 0 deletions src/common/progress.js
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
9 changes: 6 additions & 3 deletions src/common/ypccrypto.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions test/BrowserCrypto.spec.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions test/HttpSealedFileStream.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions test/progress.spec.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading