@h3l1os/mp4vault 2.0.0
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/LICENSE +661 -0
- package/README.md +228 -0
- package/dist/index.cjs +1525 -0
- package/dist/index.d.cts +261 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +1479 -0
- package/package.json +69 -0
- package/src/AES.ts +134 -0
- package/src/Atom.ts +148 -0
- package/src/Convert.ts +28 -0
- package/src/Embed.ts +243 -0
- package/src/EmbedBinary.ts +204 -0
- package/src/EmbedObject.ts +231 -0
- package/src/MP4.ts +363 -0
- package/src/Pack.ts +11 -0
- package/src/constants.ts +3 -0
- package/src/index.ts +11 -0
- package/src/jspack.d.ts +10 -0
- package/src/jspack.js +319 -0
- package/src/node/Readable.ts +61 -0
- package/src/node/Writable.ts +85 -0
- package/src/types.ts +44 -0
- package/src/utils.ts +6 -0
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@h3l1os/mp4vault",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Hide and extract files within MP4 video containers with AES-256-GCM encryption",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"!dist/*.map",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"mp4",
|
|
38
|
+
"vault",
|
|
39
|
+
"secure",
|
|
40
|
+
"embed",
|
|
41
|
+
"extract",
|
|
42
|
+
"encryption",
|
|
43
|
+
"aes-256-gcm",
|
|
44
|
+
"video",
|
|
45
|
+
"container"
|
|
46
|
+
],
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/h3l1os-sol/mp4vault.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/h3l1os-sol/mp4vault",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/h3l1os-sol/mp4vault/issues"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"debug": "^4.3.1",
|
|
57
|
+
"tmp": "^0.2.1"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/debug": "^4.1.12",
|
|
61
|
+
"@types/node": "^25.4.0",
|
|
62
|
+
"@types/tmp": "^0.2.6",
|
|
63
|
+
"tsup": "^8.5.1",
|
|
64
|
+
"typescript": "^5.9.3",
|
|
65
|
+
"vitest": "^4.0.18"
|
|
66
|
+
},
|
|
67
|
+
"author": "h3l1os-sol",
|
|
68
|
+
"license": "AGPL-3.0"
|
|
69
|
+
}
|
package/src/AES.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
const IV_BYTE_LENGTH = 12;
|
|
4
|
+
const SALT_BYTE_LENGTH = 16;
|
|
5
|
+
const AUTH_TAG_BYTE_LENGTH = 16;
|
|
6
|
+
const KEY_BYTE_LENGTH = 32; // AES-256
|
|
7
|
+
const PBKDF2_ITERATIONS = 600000;
|
|
8
|
+
const PBKDF2_DIGEST = 'sha512';
|
|
9
|
+
|
|
10
|
+
export interface AESParams {
|
|
11
|
+
key?: Buffer | Uint8Array;
|
|
12
|
+
password?: string;
|
|
13
|
+
iv?: Buffer;
|
|
14
|
+
salt?: Buffer;
|
|
15
|
+
authTag?: Buffer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class AES {
|
|
19
|
+
static readonly ivByteLength = IV_BYTE_LENGTH;
|
|
20
|
+
static readonly saltByteLength = SALT_BYTE_LENGTH;
|
|
21
|
+
static readonly authTagByteLength = AUTH_TAG_BYTE_LENGTH;
|
|
22
|
+
|
|
23
|
+
private _key: Buffer;
|
|
24
|
+
private _iv: Buffer;
|
|
25
|
+
private _salt: Buffer | null;
|
|
26
|
+
private _authTag: Buffer | null;
|
|
27
|
+
private _cipher: crypto.CipherGCM | null = null;
|
|
28
|
+
private _decipher: crypto.DecipherGCM | null = null;
|
|
29
|
+
|
|
30
|
+
constructor(params: AESParams) {
|
|
31
|
+
this._iv = params.iv || crypto.randomBytes(IV_BYTE_LENGTH);
|
|
32
|
+
this._salt = params.salt || null;
|
|
33
|
+
this._authTag = params.authTag || null;
|
|
34
|
+
|
|
35
|
+
if (params.password) {
|
|
36
|
+
if (!this._salt) {
|
|
37
|
+
this._salt = crypto.randomBytes(SALT_BYTE_LENGTH);
|
|
38
|
+
}
|
|
39
|
+
this._key = AES.deriveKey(params.password, this._salt);
|
|
40
|
+
} else if (params.key) {
|
|
41
|
+
this._key = AES._normalizeKey(params.key);
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error('Either key or password is required');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static _normalizeKey(key: Buffer | Uint8Array): Buffer {
|
|
48
|
+
if (Buffer.isBuffer(key)) {
|
|
49
|
+
if (key.length !== 16 && key.length !== 24 && key.length !== 32) {
|
|
50
|
+
throw new Error('Key must be 16, 24, or 32 bytes. Got ' + key.length);
|
|
51
|
+
}
|
|
52
|
+
return key;
|
|
53
|
+
}
|
|
54
|
+
if (key instanceof Uint8Array) {
|
|
55
|
+
if (key.length !== 16 && key.length !== 24 && key.length !== 32) {
|
|
56
|
+
throw new Error('Key must be 16, 24, or 32 bytes. Got ' + key.length);
|
|
57
|
+
}
|
|
58
|
+
return Buffer.from(key);
|
|
59
|
+
}
|
|
60
|
+
throw new Error('Key must be a Buffer or Uint8Array');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private static _algorithmForKey(key: Buffer): string {
|
|
64
|
+
switch (key.length) {
|
|
65
|
+
case 16: return 'aes-128-gcm';
|
|
66
|
+
case 24: return 'aes-192-gcm';
|
|
67
|
+
case 32: return 'aes-256-gcm';
|
|
68
|
+
default: throw new Error('Key must be 16, 24, or 32 bytes. Got ' + key.length);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static deriveKey(password: string, salt: Buffer): Buffer {
|
|
73
|
+
return crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_BYTE_LENGTH, PBKDF2_DIGEST);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
encrypt(chunk: Buffer | null, finalize = false): Buffer {
|
|
77
|
+
if (!this._cipher) {
|
|
78
|
+
const algo = AES._algorithmForKey(this._key);
|
|
79
|
+
this._cipher = crypto.createCipheriv(algo, this._key, this._iv) as crypto.CipherGCM;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (finalize) {
|
|
83
|
+
if (chunk && chunk.length > 0) {
|
|
84
|
+
const processed = this._cipher.update(chunk);
|
|
85
|
+
const final = this._cipher.final();
|
|
86
|
+
this._authTag = this._cipher.getAuthTag();
|
|
87
|
+
return Buffer.concat([processed, final]);
|
|
88
|
+
} else {
|
|
89
|
+
const final = this._cipher.final();
|
|
90
|
+
this._authTag = this._cipher.getAuthTag();
|
|
91
|
+
return final;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
return this._cipher.update(chunk!);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
decrypt(chunk: Buffer | null, finalize = false): Buffer {
|
|
99
|
+
if (!this._decipher) {
|
|
100
|
+
const algo = AES._algorithmForKey(this._key);
|
|
101
|
+
this._decipher = crypto.createDecipheriv(algo, this._key, this._iv) as crypto.DecipherGCM;
|
|
102
|
+
if (this._authTag) {
|
|
103
|
+
this._decipher.setAuthTag(this._authTag);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (finalize) {
|
|
108
|
+
if (chunk && chunk.length > 0) {
|
|
109
|
+
const processed = this._decipher.update(chunk);
|
|
110
|
+
const final = this._decipher.final();
|
|
111
|
+
return Buffer.concat([processed, final]);
|
|
112
|
+
} else {
|
|
113
|
+
return this._decipher.final();
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
return this._decipher.update(chunk!);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getAuthTag(): Buffer {
|
|
121
|
+
if (!this._authTag) {
|
|
122
|
+
throw new Error('Auth tag not available. Call encrypt with finalize=true first');
|
|
123
|
+
}
|
|
124
|
+
return this._authTag;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getIV(): Buffer {
|
|
128
|
+
return this._iv;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getSalt(): Buffer | null {
|
|
132
|
+
return this._salt;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/Atom.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Pack } from './Pack.js';
|
|
2
|
+
import { BUFFER_SIZE, MAX_INT32 } from './constants.js';
|
|
3
|
+
import type { IReadable, IWritable } from './types.js';
|
|
4
|
+
|
|
5
|
+
export class Atom {
|
|
6
|
+
readable: IReadable | null;
|
|
7
|
+
name: string;
|
|
8
|
+
start: number;
|
|
9
|
+
size: number;
|
|
10
|
+
header_size: number;
|
|
11
|
+
mother: Atom | null;
|
|
12
|
+
children: Atom[];
|
|
13
|
+
contents: number[] | null;
|
|
14
|
+
|
|
15
|
+
constructor(params: {
|
|
16
|
+
readable?: IReadable;
|
|
17
|
+
name: string;
|
|
18
|
+
start: number;
|
|
19
|
+
size: number;
|
|
20
|
+
header_size: number;
|
|
21
|
+
mother?: Atom | null;
|
|
22
|
+
}) {
|
|
23
|
+
this.readable = params.readable || null;
|
|
24
|
+
this.name = params.name;
|
|
25
|
+
this.start = params.start;
|
|
26
|
+
this.size = params.size;
|
|
27
|
+
this.header_size = params.header_size;
|
|
28
|
+
this.mother = params.mother || null;
|
|
29
|
+
this.children = [];
|
|
30
|
+
this.contents = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
findAtoms(atoms: Atom[] | null, name: string): Atom[] {
|
|
34
|
+
atoms = atoms || this.children;
|
|
35
|
+
|
|
36
|
+
let ret: Atom[] = [];
|
|
37
|
+
for (const a of atoms) {
|
|
38
|
+
if (a.name === name) {
|
|
39
|
+
ret.push(a);
|
|
40
|
+
}
|
|
41
|
+
if (a.children.length) {
|
|
42
|
+
ret = ret.concat(this.findAtoms(a.children, name));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return ret;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async unpackFromOffset(offset: number, length: number, fmt: string): Promise<number[]> {
|
|
50
|
+
try {
|
|
51
|
+
const data = await this.readable!.getSlice(this.start + offset, length);
|
|
52
|
+
return Pack.unpack(fmt, data);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
isVideo(): boolean {
|
|
59
|
+
const vmhdAtoms = this.findAtoms(null, 'vmhd');
|
|
60
|
+
return !!(vmhdAtoms && vmhdAtoms.length);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isAudio(): boolean {
|
|
64
|
+
const smhdAtoms = this.findAtoms(null, 'smhd');
|
|
65
|
+
return !!(smhdAtoms && smhdAtoms.length);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getChunkOffsets(): Promise<number[]> {
|
|
69
|
+
let sampleOffsets: number[] = [];
|
|
70
|
+
if (this.name !== 'stco' && this.name !== 'co64') {
|
|
71
|
+
const sampleAtoms = this.findAtoms(null, 'stco').concat(this.findAtoms(null, 'co64'));
|
|
72
|
+
for (const atom of sampleAtoms) {
|
|
73
|
+
sampleOffsets = sampleOffsets.concat(await atom.getChunkOffsets());
|
|
74
|
+
}
|
|
75
|
+
return sampleOffsets;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = await this.readable!.getSlice(this.start + this.header_size, 8);
|
|
79
|
+
const unpacked = Pack.unpack('>II', data);
|
|
80
|
+
const count = unpacked[1];
|
|
81
|
+
|
|
82
|
+
if (this.name === 'stco') {
|
|
83
|
+
for (let i = 0; i < count; i += 1024) {
|
|
84
|
+
const cToRead = Math.min(1024, count - i);
|
|
85
|
+
const readOffsets = Pack.unpack(
|
|
86
|
+
'>' + 'I'.repeat(cToRead),
|
|
87
|
+
await this.readable!.getSlice(this.start + this.header_size + 8 + i * 4, cToRead * 4),
|
|
88
|
+
);
|
|
89
|
+
sampleOffsets.push(...readOffsets);
|
|
90
|
+
}
|
|
91
|
+
} else if (this.name === 'co64') {
|
|
92
|
+
for (let i = 0; i < count; i += 1024) {
|
|
93
|
+
const cToRead = Math.min(1024, count - i);
|
|
94
|
+
const readOffsets = Pack.unpack(
|
|
95
|
+
'>' + 'Q'.repeat(cToRead),
|
|
96
|
+
await this.readable!.getSlice(this.start + this.header_size + 8 + i * 8, cToRead * 8),
|
|
97
|
+
);
|
|
98
|
+
sampleOffsets.push(...readOffsets);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return sampleOffsets;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async writeHeader(writable: IWritable): Promise<void> {
|
|
106
|
+
if (this.size > MAX_INT32 && this.header_size === 8) {
|
|
107
|
+
throw new Error('Size too large for compact header');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (this.size < MAX_INT32) {
|
|
111
|
+
await writable.write(Pack.pack('>I4s', [this.size, this.name]));
|
|
112
|
+
} else {
|
|
113
|
+
await writable.write(Pack.pack('>I4sQ', [1, this.name, this.size]));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async writePayload(writable: IWritable): Promise<void> {
|
|
118
|
+
if (this.children.length) {
|
|
119
|
+
for (const a of this.children) {
|
|
120
|
+
await a.write(writable);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const bodySize = this.size - this.header_size;
|
|
124
|
+
if (this.readable) {
|
|
125
|
+
for (let i = 0; i < bodySize; i += BUFFER_SIZE) {
|
|
126
|
+
const copySize = Math.min(BUFFER_SIZE, bodySize - i);
|
|
127
|
+
const chunk = await this.readable.getSlice(this.start + this.header_size + i, copySize);
|
|
128
|
+
await writable.write(chunk);
|
|
129
|
+
}
|
|
130
|
+
} else if (this.contents) {
|
|
131
|
+
if (this.contents.length === bodySize) {
|
|
132
|
+
await writable.write(this.contents);
|
|
133
|
+
} else {
|
|
134
|
+
throw new Error('Invalid bodySize for contents chunk');
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
if (bodySize > 0) {
|
|
138
|
+
await writable.write(new Uint8Array([0]));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async write(writable: IWritable): Promise<void> {
|
|
145
|
+
await this.writeHeader(writable);
|
|
146
|
+
await this.writePayload(writable);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/Convert.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
export class Convert {
|
|
4
|
+
static randomByteIn(maxOptions: number, option: number): number {
|
|
5
|
+
const maxMultiple = Math.floor(256 / maxOptions);
|
|
6
|
+
const randomValue = crypto.randomInt(maxMultiple);
|
|
7
|
+
return randomValue * maxOptions + option;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static isByteIn(byte: number, maxOptions: number, option: number): boolean {
|
|
11
|
+
return (byte % maxOptions === option);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static objectToBuffer(object: unknown): Buffer {
|
|
15
|
+
return Buffer.from(JSON.stringify(object), 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static bufferToObject(buffer: Buffer | Uint8Array): unknown {
|
|
19
|
+
return JSON.parse(Buffer.from(buffer).toString('utf-8'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static hexStringToBuffer(str: string): Buffer {
|
|
23
|
+
if (typeof str !== 'string' || !/^[0-9a-fA-F]*$/.test(str) || str.length % 2 !== 0) {
|
|
24
|
+
throw new Error('Invalid hex string');
|
|
25
|
+
}
|
|
26
|
+
return Buffer.from(str, 'hex');
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/Embed.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { EmbedObject } from './EmbedObject.js';
|
|
2
|
+
import { EmbedBinary } from './EmbedBinary.js';
|
|
3
|
+
import type { IReadable, IWritable, FileRecord } from './types.js';
|
|
4
|
+
|
|
5
|
+
interface FileEntity {
|
|
6
|
+
filename: string | null;
|
|
7
|
+
embedBinary: EmbedBinary;
|
|
8
|
+
isEncrypted: boolean;
|
|
9
|
+
meta?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class Embed {
|
|
13
|
+
private _files: FileEntity[] = [];
|
|
14
|
+
private _headerEmbed: EmbedObject | null = null;
|
|
15
|
+
_publicHeaderEmbed: EmbedObject | null = null;
|
|
16
|
+
|
|
17
|
+
hasEncryptedFiles = false;
|
|
18
|
+
hasPublicFiles = false;
|
|
19
|
+
|
|
20
|
+
private _key: Buffer | null;
|
|
21
|
+
private _password: string | null;
|
|
22
|
+
|
|
23
|
+
constructor(params: {
|
|
24
|
+
mp4?: unknown;
|
|
25
|
+
key?: Buffer | null;
|
|
26
|
+
password?: string | null;
|
|
27
|
+
} = {}) {
|
|
28
|
+
this._key = params.key || null;
|
|
29
|
+
this._password = params.password || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private basename(path: string): string {
|
|
33
|
+
return ('' + path).split(/[\\/]/).pop() || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async addFile(params: {
|
|
37
|
+
file?: { name?: string };
|
|
38
|
+
filename?: string | null;
|
|
39
|
+
meta?: Record<string, unknown>;
|
|
40
|
+
key?: Buffer | null;
|
|
41
|
+
password?: string | null;
|
|
42
|
+
}): Promise<void> {
|
|
43
|
+
const file = params.file || null;
|
|
44
|
+
let filename = params.filename || null;
|
|
45
|
+
const meta = params.meta || null;
|
|
46
|
+
|
|
47
|
+
// Per-file encryption opt-out: setting params.key or params.password to null
|
|
48
|
+
// explicitly disables encryption for this file, even if the Embed instance has a key/password.
|
|
49
|
+
// This enables mixing public and encrypted files in the same container.
|
|
50
|
+
let isEncrypted = false;
|
|
51
|
+
if ((this._key || this._password) && params.key !== null && params.password !== null) {
|
|
52
|
+
isEncrypted = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let embedBinary: EmbedBinary;
|
|
56
|
+
if (isEncrypted) {
|
|
57
|
+
embedBinary = new EmbedBinary({
|
|
58
|
+
filename: filename || undefined,
|
|
59
|
+
file: file || undefined,
|
|
60
|
+
key: this._key,
|
|
61
|
+
password: this._password,
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
embedBinary = new EmbedBinary({
|
|
65
|
+
filename: filename || undefined,
|
|
66
|
+
file: file || undefined,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!filename && file) {
|
|
71
|
+
filename = file.name || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fileEntity: FileEntity = {
|
|
75
|
+
filename: filename,
|
|
76
|
+
embedBinary: embedBinary,
|
|
77
|
+
isEncrypted: isEncrypted,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (meta) {
|
|
81
|
+
fileEntity.meta = meta;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this._files.push(fileEntity);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async composeHeader(): Promise<boolean> {
|
|
88
|
+
const headerObject: { files: FileRecord[] } = { files: [] };
|
|
89
|
+
const publicHeaderObject: { files: FileRecord[] } = { files: [] };
|
|
90
|
+
|
|
91
|
+
for (const fileEntity of this._files) {
|
|
92
|
+
const size = await fileEntity.embedBinary.getExpectedSize();
|
|
93
|
+
const fileRecord: FileRecord = {
|
|
94
|
+
filename: this.basename(fileEntity.filename || ''),
|
|
95
|
+
size: size,
|
|
96
|
+
};
|
|
97
|
+
if (fileEntity.meta) {
|
|
98
|
+
fileRecord.meta = fileEntity.meta;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (fileEntity.isEncrypted) {
|
|
102
|
+
headerObject.files.push(fileRecord);
|
|
103
|
+
this.hasEncryptedFiles = true;
|
|
104
|
+
} else {
|
|
105
|
+
publicHeaderObject.files.push(fileRecord);
|
|
106
|
+
this.hasPublicFiles = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this._publicHeaderEmbed = new EmbedObject({ object: publicHeaderObject });
|
|
111
|
+
this._headerEmbed = new EmbedObject({
|
|
112
|
+
object: headerObject,
|
|
113
|
+
key: this._key,
|
|
114
|
+
password: this._password,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async getExpectedSize(): Promise<number> {
|
|
121
|
+
await this.composeHeader();
|
|
122
|
+
let size = 0;
|
|
123
|
+
if (this.hasEncryptedFiles) {
|
|
124
|
+
size += await this._headerEmbed!.getExpectedSize();
|
|
125
|
+
}
|
|
126
|
+
if (this.hasPublicFiles) {
|
|
127
|
+
size += await this._publicHeaderEmbed!.getExpectedSize();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const encFiles = (this._headerEmbed!.object as { files: FileRecord[] }).files;
|
|
131
|
+
for (const file of encFiles) {
|
|
132
|
+
size += file.size;
|
|
133
|
+
}
|
|
134
|
+
const pubFiles = (this._publicHeaderEmbed!.object as { files: FileRecord[] }).files;
|
|
135
|
+
for (const file of pubFiles) {
|
|
136
|
+
size += file.size;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return size;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async writeTo(writable: IWritable): Promise<void> {
|
|
143
|
+
await this.composeHeader();
|
|
144
|
+
if (this.hasPublicFiles) {
|
|
145
|
+
await this._publicHeaderEmbed!.writeTo(writable);
|
|
146
|
+
}
|
|
147
|
+
if (this.hasEncryptedFiles) {
|
|
148
|
+
await this._headerEmbed!.writeTo(writable);
|
|
149
|
+
}
|
|
150
|
+
for (const file of this._files) {
|
|
151
|
+
await file.embedBinary.writeTo(writable);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async restoreFromReadable(
|
|
156
|
+
readable: IReadable,
|
|
157
|
+
params: { key?: Buffer; password?: string | null } = {},
|
|
158
|
+
offset = 0,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const publicParams: { password?: string | null; key?: Buffer } = {};
|
|
161
|
+
Object.assign(publicParams, params);
|
|
162
|
+
Object.assign(publicParams, { password: null, key: undefined });
|
|
163
|
+
|
|
164
|
+
let encryptedHeaderOffset = 0;
|
|
165
|
+
try {
|
|
166
|
+
this._publicHeaderEmbed = await EmbedObject.restoreFromReadable(
|
|
167
|
+
readable,
|
|
168
|
+
publicParams as Parameters<typeof EmbedObject.restoreFromReadable>[1],
|
|
169
|
+
offset,
|
|
170
|
+
);
|
|
171
|
+
encryptedHeaderOffset = this._publicHeaderEmbed.readBytes;
|
|
172
|
+
} catch {
|
|
173
|
+
this._publicHeaderEmbed = new EmbedObject({ object: { files: [] } });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const pubObj = this._publicHeaderEmbed._object as { files?: unknown[] } | null;
|
|
177
|
+
this.hasPublicFiles = !!(pubObj && pubObj.files && pubObj.files.length);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
this._headerEmbed = await EmbedObject.restoreFromReadable(
|
|
181
|
+
readable,
|
|
182
|
+
params as Parameters<typeof EmbedObject.restoreFromReadable>[1],
|
|
183
|
+
offset + encryptedHeaderOffset,
|
|
184
|
+
);
|
|
185
|
+
} catch {
|
|
186
|
+
this._headerEmbed = new EmbedObject({
|
|
187
|
+
object: { files: [] },
|
|
188
|
+
key: this._key,
|
|
189
|
+
password: this._password,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const encObj = this._headerEmbed._object as { files?: unknown[] } | null;
|
|
194
|
+
this.hasEncryptedFiles = !!(encObj && encObj.files && encObj.files.length);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getFilesToExtract(): FileRecord[] {
|
|
198
|
+
const filesToExtract: FileRecord[] = [];
|
|
199
|
+
let offset = 0;
|
|
200
|
+
offset += this._publicHeaderEmbed!.readBytes;
|
|
201
|
+
offset += this._headerEmbed!.readBytes;
|
|
202
|
+
|
|
203
|
+
const pubFiles = (this._publicHeaderEmbed!._object as { files: FileRecord[] }).files;
|
|
204
|
+
for (const fileRecord of pubFiles) {
|
|
205
|
+
filesToExtract.push({ ...fileRecord, isEncrypted: false, offset: offset });
|
|
206
|
+
offset += fileRecord.size;
|
|
207
|
+
}
|
|
208
|
+
const encFiles = (this._headerEmbed!._object as { files: FileRecord[] }).files;
|
|
209
|
+
for (const fileRecord of encFiles) {
|
|
210
|
+
filesToExtract.push({ ...fileRecord, isEncrypted: true, offset: offset });
|
|
211
|
+
offset += fileRecord.size;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return filesToExtract;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async restoreBinary(
|
|
218
|
+
readable: IReadable,
|
|
219
|
+
params: { key?: Buffer; password?: string | undefined },
|
|
220
|
+
n: number,
|
|
221
|
+
offset: number,
|
|
222
|
+
writable: IWritable | null = null,
|
|
223
|
+
): Promise<IWritable> {
|
|
224
|
+
if (!this._headerEmbed && !this._publicHeaderEmbed) {
|
|
225
|
+
await this.restoreFromReadable(readable, params, offset);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const filesToExtract = this.getFilesToExtract();
|
|
229
|
+
if (!filesToExtract[n]) {
|
|
230
|
+
throw new Error('There is no file ' + n + ' found in this container');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fileSize = filesToExtract[n].size;
|
|
234
|
+
const fileOffset = offset + filesToExtract[n].offset!;
|
|
235
|
+
|
|
236
|
+
if (filesToExtract[n].isEncrypted) {
|
|
237
|
+
return await EmbedBinary.restoreFromReadable(readable, params, fileOffset, fileSize, writable);
|
|
238
|
+
} else {
|
|
239
|
+
const publicParams = { ...params, password: undefined, key: undefined };
|
|
240
|
+
return await EmbedBinary.restoreFromReadable(readable, publicParams, fileOffset, fileSize, writable);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|