@majikah/majik-signature 0.6.0 → 0.7.1
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/dist/core/embed/fallback.d.ts +121 -5
- package/dist/core/embed/fallback.js +121 -5
- package/dist/core/embed/handlers/flac.d.ts +9 -15
- package/dist/core/embed/handlers/flac.js +113 -129
- package/dist/core/embed/handlers/index.d.ts +10 -0
- package/dist/core/embed/handlers/index.js +10 -0
- package/dist/core/embed/majik-embed.d.ts +616 -93
- package/dist/core/embed/majik-embed.js +542 -128
- package/dist/core/envelope.d.ts +652 -0
- package/dist/core/envelope.js +592 -0
- package/dist/core/payload.d.ts +162 -14
- package/dist/core/payload.js +89 -11
- package/dist/core/types.d.ts +820 -170
- package/dist/core/types.js +6 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +8 -6
|
@@ -1,18 +1,134 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* handlers/fallback.ts — Tier-2 universal trailer handler
|
|
2
|
+
* handlers/fallback.ts — Tier-2 universal trailer handler.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The universal fallback is the final embedding strategy used when no
|
|
5
|
+
* format-specific handler recognizes a file. It can therefore operate on
|
|
6
|
+
* arbitrary binary content without needing to understand the file format.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Trailer layout:
|
|
9
|
+
* [original bytes]
|
|
10
|
+
* [signature JSON encoded as UTF-8]
|
|
11
|
+
* [8-byte payload length, little-endian]
|
|
12
|
+
* [8-byte magic marker]
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* The handler intentionally treats the signature payload as opaque JSON text.
|
|
16
|
+
* Format awareness, cryptographic signing, and signature verification remain
|
|
17
|
+
* outside this handler.
|
|
18
|
+
*
|
|
19
|
+
* The fallback is designed for formats whose parsers tolerate trailing bytes.
|
|
20
|
+
* That is a compatibility strategy, not a universal guarantee: some formats
|
|
21
|
+
* or downstream tools may reject, rewrite, truncate, or otherwise normalize
|
|
22
|
+
* trailing data. Prefer a native format handler when one is available and
|
|
23
|
+
* durable cross-tool interoperability matters.
|
|
24
|
+
*
|
|
25
|
+
* Embedding is idempotent. Before writing a new trailer, any existing
|
|
26
|
+
* universal trailer is removed so signatures do not accumulate back-to-back.
|
|
9
27
|
*/
|
|
10
28
|
import { FormatHandler } from "../types";
|
|
29
|
+
/**
|
|
30
|
+
* Last-resort {@link FormatHandler} that embeds signatures using the universal
|
|
31
|
+
* binary trailer format.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* `FallbackHandler` is intended to be registered after format-specific
|
|
35
|
+
* handlers. Its {@link canHandle} method always returns `true`, making this
|
|
36
|
+
* handler the universal catch-all for otherwise unsupported file types.
|
|
37
|
+
*
|
|
38
|
+
* The handler does not perform cryptographic operations itself. It only:
|
|
39
|
+
* - appends a serialized signature payload,
|
|
40
|
+
* - extracts that payload when the trailer is present, and
|
|
41
|
+
* - removes the trailer to recover the original bytes.
|
|
42
|
+
*
|
|
43
|
+
* The trailer is self-describing through its terminating magic bytes and
|
|
44
|
+
* length field, allowing extraction and stripping without knowing the file's
|
|
45
|
+
* MIME type or format.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const handler = new FallbackHandler();
|
|
50
|
+
*
|
|
51
|
+
* const signed = await handler.embed(bytes, signatureJson);
|
|
52
|
+
* const extracted = await handler.extract(signed);
|
|
53
|
+
* const original = await handler.strip(signed);
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @see {@link FormatHandler}
|
|
57
|
+
*/
|
|
11
58
|
export declare class FallbackHandler implements FormatHandler {
|
|
59
|
+
/**
|
|
60
|
+
* Human-readable handler name used by the embedding pipeline and diagnostics.
|
|
61
|
+
*/
|
|
12
62
|
readonly name = "Fallback (Universal Trailer)";
|
|
63
|
+
/**
|
|
64
|
+
* MIME types accepted by this handler.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Intentionally declares a catch-all rather than a concrete media
|
|
68
|
+
* type. Actual selection is still governed by handler ordering and
|
|
69
|
+
* `canHandle()`.
|
|
70
|
+
*/
|
|
13
71
|
readonly supportedMimeTypes: readonly ["*/*"];
|
|
72
|
+
/**
|
|
73
|
+
* Determine whether this handler can process the supplied content.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* This method always returns `true` because the universal trailer is the
|
|
77
|
+
* Tier-2 last-resort embedding mechanism. It should therefore remain the
|
|
78
|
+
* final handler in the handler-selection chain; placing it earlier would
|
|
79
|
+
* prevent more format-aware handlers from being selected.
|
|
80
|
+
*
|
|
81
|
+
* The byte content and MIME type are intentionally ignored.
|
|
82
|
+
*
|
|
83
|
+
* @param _bytes File bytes to inspect. Ignored by the fallback handler.
|
|
84
|
+
* @param _mimeType Optional MIME type hint. Ignored by the fallback handler.
|
|
85
|
+
* @returns Always `true`.
|
|
86
|
+
*/
|
|
14
87
|
canHandle(_bytes: Uint8Array, _mimeType?: string): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Embed a serialized signature payload using the universal trailer format.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* Any existing universal trailer is stripped before the new payload is
|
|
93
|
+
* appended. This makes repeated embedding idempotent: re-embedding replaces
|
|
94
|
+
* the existing fallback signature rather than stacking multiple trailers.
|
|
95
|
+
*
|
|
96
|
+
* The supplied `signatureJson` is treated as already-serialized signature
|
|
97
|
+
* data. This method does not parse, validate, or cryptographically verify
|
|
98
|
+
* it.
|
|
99
|
+
*
|
|
100
|
+
* @param bytes Original or previously signed file bytes.
|
|
101
|
+
* @param signatureJson Serialized signature envelope JSON to append.
|
|
102
|
+
* @returns The file bytes with exactly one universal signature trailer.
|
|
103
|
+
*/
|
|
15
104
|
embed(bytes: Uint8Array, signatureJson: string): Promise<Uint8Array>;
|
|
105
|
+
/**
|
|
106
|
+
* Extract the serialized signature payload from a universal trailer.
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* Extraction is structural only. A non-null return value means a well-formed
|
|
110
|
+
* universal trailer was found; it does not mean that the contained signature
|
|
111
|
+
* is cryptographically valid or trusted.
|
|
112
|
+
*
|
|
113
|
+
* @param bytes File bytes that may contain a universal trailer.
|
|
114
|
+
* @returns The serialized signature JSON when a valid trailer is present;
|
|
115
|
+
* otherwise `null`.
|
|
116
|
+
*/
|
|
16
117
|
extract(bytes: Uint8Array): Promise<string | null>;
|
|
118
|
+
/**
|
|
119
|
+
* Remove the universal trailer and recover the original file bytes.
|
|
120
|
+
*
|
|
121
|
+
* @remarks
|
|
122
|
+
* If no recognizable universal trailer is present, the input bytes are
|
|
123
|
+
* returned unchanged.
|
|
124
|
+
*
|
|
125
|
+
* This operation only removes the trailer produced by the universal
|
|
126
|
+
* fallback format. It does not remove signatures embedded by native,
|
|
127
|
+
* format-specific handlers.
|
|
128
|
+
*
|
|
129
|
+
* @param bytes File bytes that may contain a universal trailer.
|
|
130
|
+
* @returns The original bytes when a trailer is found; otherwise the
|
|
131
|
+
* unchanged input bytes.
|
|
132
|
+
*/
|
|
17
133
|
strip(bytes: Uint8Array): Promise<Uint8Array>;
|
|
18
134
|
}
|
|
@@ -1,29 +1,145 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* handlers/fallback.ts — Tier-2 universal trailer handler
|
|
2
|
+
* handlers/fallback.ts — Tier-2 universal trailer handler.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The universal fallback is the final embedding strategy used when no
|
|
5
|
+
* format-specific handler recognizes a file. It can therefore operate on
|
|
6
|
+
* arbitrary binary content without needing to understand the file format.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Trailer layout:
|
|
9
|
+
* [original bytes]
|
|
10
|
+
* [signature JSON encoded as UTF-8]
|
|
11
|
+
* [8-byte payload length, little-endian]
|
|
12
|
+
* [8-byte magic marker]
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* The handler intentionally treats the signature payload as opaque JSON text.
|
|
16
|
+
* Format awareness, cryptographic signing, and signature verification remain
|
|
17
|
+
* outside this handler.
|
|
18
|
+
*
|
|
19
|
+
* The fallback is designed for formats whose parsers tolerate trailing bytes.
|
|
20
|
+
* That is a compatibility strategy, not a universal guarantee: some formats
|
|
21
|
+
* or downstream tools may reject, rewrite, truncate, or otherwise normalize
|
|
22
|
+
* trailing data. Prefer a native format handler when one is available and
|
|
23
|
+
* durable cross-tool interoperability matters.
|
|
24
|
+
*
|
|
25
|
+
* Embedding is idempotent. Before writing a new trailer, any existing
|
|
26
|
+
* universal trailer is removed so signatures do not accumulate back-to-back.
|
|
9
27
|
*/
|
|
10
28
|
import { appendTrailer, extractTrailer } from "./utils";
|
|
29
|
+
/**
|
|
30
|
+
* Last-resort {@link FormatHandler} that embeds signatures using the universal
|
|
31
|
+
* binary trailer format.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* `FallbackHandler` is intended to be registered after format-specific
|
|
35
|
+
* handlers. Its {@link canHandle} method always returns `true`, making this
|
|
36
|
+
* handler the universal catch-all for otherwise unsupported file types.
|
|
37
|
+
*
|
|
38
|
+
* The handler does not perform cryptographic operations itself. It only:
|
|
39
|
+
* - appends a serialized signature payload,
|
|
40
|
+
* - extracts that payload when the trailer is present, and
|
|
41
|
+
* - removes the trailer to recover the original bytes.
|
|
42
|
+
*
|
|
43
|
+
* The trailer is self-describing through its terminating magic bytes and
|
|
44
|
+
* length field, allowing extraction and stripping without knowing the file's
|
|
45
|
+
* MIME type or format.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const handler = new FallbackHandler();
|
|
50
|
+
*
|
|
51
|
+
* const signed = await handler.embed(bytes, signatureJson);
|
|
52
|
+
* const extracted = await handler.extract(signed);
|
|
53
|
+
* const original = await handler.strip(signed);
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @see {@link FormatHandler}
|
|
57
|
+
*/
|
|
11
58
|
export class FallbackHandler {
|
|
59
|
+
/**
|
|
60
|
+
* Human-readable handler name used by the embedding pipeline and diagnostics.
|
|
61
|
+
*/
|
|
12
62
|
name = "Fallback (Universal Trailer)";
|
|
63
|
+
/**
|
|
64
|
+
* MIME types accepted by this handler.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Intentionally declares a catch-all rather than a concrete media
|
|
68
|
+
* type. Actual selection is still governed by handler ordering and
|
|
69
|
+
* `canHandle()`.
|
|
70
|
+
*/
|
|
13
71
|
supportedMimeTypes = ["*/*"];
|
|
72
|
+
/**
|
|
73
|
+
* Determine whether this handler can process the supplied content.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* This method always returns `true` because the universal trailer is the
|
|
77
|
+
* Tier-2 last-resort embedding mechanism. It should therefore remain the
|
|
78
|
+
* final handler in the handler-selection chain; placing it earlier would
|
|
79
|
+
* prevent more format-aware handlers from being selected.
|
|
80
|
+
*
|
|
81
|
+
* The byte content and MIME type are intentionally ignored.
|
|
82
|
+
*
|
|
83
|
+
* @param _bytes File bytes to inspect. Ignored by the fallback handler.
|
|
84
|
+
* @param _mimeType Optional MIME type hint. Ignored by the fallback handler.
|
|
85
|
+
* @returns Always `true`.
|
|
86
|
+
*/
|
|
14
87
|
canHandle(_bytes, _mimeType) {
|
|
15
88
|
// Always handles — it's the last resort
|
|
16
89
|
return true;
|
|
17
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Embed a serialized signature payload using the universal trailer format.
|
|
93
|
+
*
|
|
94
|
+
* @remarks
|
|
95
|
+
* Any existing universal trailer is stripped before the new payload is
|
|
96
|
+
* appended. This makes repeated embedding idempotent: re-embedding replaces
|
|
97
|
+
* the existing fallback signature rather than stacking multiple trailers.
|
|
98
|
+
*
|
|
99
|
+
* The supplied `signatureJson` is treated as already-serialized signature
|
|
100
|
+
* data. This method does not parse, validate, or cryptographically verify
|
|
101
|
+
* it.
|
|
102
|
+
*
|
|
103
|
+
* @param bytes Original or previously signed file bytes.
|
|
104
|
+
* @param signatureJson Serialized signature envelope JSON to append.
|
|
105
|
+
* @returns The file bytes with exactly one universal signature trailer.
|
|
106
|
+
*/
|
|
18
107
|
async embed(bytes, signatureJson) {
|
|
19
108
|
// Strip any existing trailer first (idempotent)
|
|
20
109
|
const stripped = await this.strip(bytes);
|
|
21
110
|
return appendTrailer(stripped, signatureJson);
|
|
22
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Extract the serialized signature payload from a universal trailer.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* Extraction is structural only. A non-null return value means a well-formed
|
|
117
|
+
* universal trailer was found; it does not mean that the contained signature
|
|
118
|
+
* is cryptographically valid or trusted.
|
|
119
|
+
*
|
|
120
|
+
* @param bytes File bytes that may contain a universal trailer.
|
|
121
|
+
* @returns The serialized signature JSON when a valid trailer is present;
|
|
122
|
+
* otherwise `null`.
|
|
123
|
+
*/
|
|
23
124
|
async extract(bytes) {
|
|
24
125
|
const result = extractTrailer(bytes);
|
|
25
126
|
return result ? result.signatureJson : null;
|
|
26
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Remove the universal trailer and recover the original file bytes.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* If no recognizable universal trailer is present, the input bytes are
|
|
133
|
+
* returned unchanged.
|
|
134
|
+
*
|
|
135
|
+
* This operation only removes the trailer produced by the universal
|
|
136
|
+
* fallback format. It does not remove signatures embedded by native,
|
|
137
|
+
* format-specific handlers.
|
|
138
|
+
*
|
|
139
|
+
* @param bytes File bytes that may contain a universal trailer.
|
|
140
|
+
* @returns The original bytes when a trailer is found; otherwise the
|
|
141
|
+
* unchanged input bytes.
|
|
142
|
+
*/
|
|
27
143
|
async strip(bytes) {
|
|
28
144
|
const result = extractTrailer(bytes);
|
|
29
145
|
return result ? result.original : bytes;
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* handlers/flac.ts — FLAC handler
|
|
3
3
|
*
|
|
4
|
-
* FLAC files start with "fLaC"
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* FLAC files start with "fLaC" (4 bytes) followed by one or more metadata blocks.
|
|
5
|
+
* Each metadata block header (4 bytes):
|
|
6
|
+
* [1B: is_last (1 bit) + block_type (7 bits)]
|
|
7
|
+
* [3B: 24-bit big-endian length]
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* VORBIS_COMMENT format (all LE):
|
|
12
|
-
* [4B vendor_length][vendor_string]
|
|
13
|
-
* [4B comment_count]
|
|
14
|
-
* [4B comment_length][comment_utf8] × comment_count
|
|
9
|
+
* We embed signatures using an APPLICATION block (type = 2) with 4-byte
|
|
10
|
+
* application ID "MAJK" followed by the signature JSON payload.
|
|
15
11
|
*/
|
|
16
12
|
import { FormatHandler } from "../../types";
|
|
17
13
|
export declare class FlacHandler implements FormatHandler {
|
|
@@ -21,9 +17,7 @@ export declare class FlacHandler implements FormatHandler {
|
|
|
21
17
|
embed(bytes: Uint8Array, signatureJson: string): Promise<Uint8Array>;
|
|
22
18
|
extract(bytes: Uint8Array): Promise<string | null>;
|
|
23
19
|
strip(bytes: Uint8Array): Promise<Uint8Array>;
|
|
24
|
-
private
|
|
25
|
-
private
|
|
26
|
-
private
|
|
27
|
-
private _serializeVorbisComment;
|
|
28
|
-
private _readVorbisComment;
|
|
20
|
+
private _parseMetadata;
|
|
21
|
+
private _buildMetadataBlock;
|
|
22
|
+
private _validateAudioStream;
|
|
29
23
|
}
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* handlers/flac.ts — FLAC handler
|
|
3
3
|
*
|
|
4
|
-
* FLAC files start with "fLaC"
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* FLAC files start with "fLaC" (4 bytes) followed by one or more metadata blocks.
|
|
5
|
+
* Each metadata block header (4 bytes):
|
|
6
|
+
* [1B: is_last (1 bit) + block_type (7 bits)]
|
|
7
|
+
* [3B: 24-bit big-endian length]
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* VORBIS_COMMENT format (all LE):
|
|
12
|
-
* [4B vendor_length][vendor_string]
|
|
13
|
-
* [4B comment_count]
|
|
14
|
-
* [4B comment_length][comment_utf8] × comment_count
|
|
9
|
+
* We embed signatures using an APPLICATION block (type = 2) with 4-byte
|
|
10
|
+
* application ID "MAJK" followed by the signature JSON payload.
|
|
15
11
|
*/
|
|
16
|
-
import { concatBytes,
|
|
12
|
+
import { concatBytes, textDecode, textEncode } from "../utils";
|
|
17
13
|
const FLAC_MAGIC = textEncode("fLaC");
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
const APPLICATION_ID = textEncode("MAJK"); // 4-byte custom Application ID
|
|
15
|
+
var FlacBlockType;
|
|
16
|
+
(function (FlacBlockType) {
|
|
17
|
+
FlacBlockType[FlacBlockType["STREAMINFO"] = 0] = "STREAMINFO";
|
|
18
|
+
FlacBlockType[FlacBlockType["PADDING"] = 1] = "PADDING";
|
|
19
|
+
FlacBlockType[FlacBlockType["APPLICATION"] = 2] = "APPLICATION";
|
|
20
|
+
FlacBlockType[FlacBlockType["SEEKTABLE"] = 3] = "SEEKTABLE";
|
|
21
|
+
FlacBlockType[FlacBlockType["VORBIS_COMMENT"] = 4] = "VORBIS_COMMENT";
|
|
22
|
+
FlacBlockType[FlacBlockType["CUESHEET"] = 5] = "CUESHEET";
|
|
23
|
+
FlacBlockType[FlacBlockType["PICTURE"] = 6] = "PICTURE";
|
|
24
|
+
})(FlacBlockType || (FlacBlockType = {}));
|
|
22
25
|
export class FlacHandler {
|
|
23
26
|
name = "FLAC";
|
|
24
27
|
supportedMimeTypes = ["audio/flac", "audio/x-flac"];
|
|
@@ -27,40 +30,44 @@ export class FlacHandler {
|
|
|
27
30
|
return true;
|
|
28
31
|
if (bytes.length < 4)
|
|
29
32
|
return false;
|
|
30
|
-
return
|
|
33
|
+
return (bytes[0] === 0x66 && // 'f'
|
|
34
|
+
bytes[1] === 0x4c && // 'L'
|
|
35
|
+
bytes[2] === 0x61 && // 'a'
|
|
36
|
+
bytes[3] === 0x43 // 'C'
|
|
37
|
+
);
|
|
31
38
|
}
|
|
32
39
|
async embed(bytes, signatureJson) {
|
|
33
40
|
const clean = await this.strip(bytes);
|
|
34
|
-
const blocks = this.
|
|
35
|
-
// Build
|
|
36
|
-
const
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
newBlocks[vcIdx] = newBlock;
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
newBlocks.splice(insertAt, 0, newBlock);
|
|
50
|
-
}
|
|
51
|
-
return this._serializeBlocks(newBlocks, clean);
|
|
41
|
+
const { blocks, audioData } = this._parseMetadata(clean);
|
|
42
|
+
// Build signature APPLICATION payload: [4B App ID "MAJK"][signature JSON]
|
|
43
|
+
const appPayload = concatBytes(APPLICATION_ID, textEncode(signatureJson));
|
|
44
|
+
// Safely clear the isLast flag bit on all existing metadata blocks
|
|
45
|
+
const updatedBlocks = blocks.map((b) => {
|
|
46
|
+
const newBlock = new Uint8Array(b.raw);
|
|
47
|
+
newBlock[0] &= 0x7f; // Clear bit 7 (isLast)
|
|
48
|
+
return newBlock;
|
|
49
|
+
});
|
|
50
|
+
// Append our new APPLICATION metadata block marked as isLast = true
|
|
51
|
+
const sigBlock = this._buildMetadataBlock(FlacBlockType.APPLICATION, appPayload, true);
|
|
52
|
+
return concatBytes(FLAC_MAGIC, ...updatedBlocks, sigBlock, audioData);
|
|
52
53
|
}
|
|
53
54
|
async extract(bytes) {
|
|
54
55
|
if (!this.canHandle(bytes))
|
|
55
56
|
return null;
|
|
56
57
|
try {
|
|
57
|
-
const blocks = this.
|
|
58
|
+
const { blocks, audioData, endOffset } = this._parseMetadata(bytes);
|
|
59
|
+
// Verify overall stream integrity and ensure no trailing garbage was appended
|
|
60
|
+
if (!this._validateAudioStream(audioData, bytes.length - endOffset)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
58
63
|
for (const block of blocks) {
|
|
59
|
-
if (block.type ===
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
if (block.type === FlacBlockType.APPLICATION &&
|
|
65
|
+
block.data.length >= 4) {
|
|
66
|
+
const appId = textDecode(block.data.slice(0, 4));
|
|
67
|
+
// Inside FlacHandler.extract()
|
|
68
|
+
if (appId === "MAJK") {
|
|
69
|
+
const jsonStr = textDecode(block.data.slice(4));
|
|
70
|
+
return jsonStr.replace(/\0+$/, ""); // Strip trailing null padding if present
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
}
|
|
@@ -74,114 +81,91 @@ export class FlacHandler {
|
|
|
74
81
|
if (!this.canHandle(bytes))
|
|
75
82
|
return bytes;
|
|
76
83
|
try {
|
|
77
|
-
const blocks = this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
const { blocks, audioData } = this._parseMetadata(bytes);
|
|
85
|
+
// Filter out MAJK application metadata blocks
|
|
86
|
+
const kept = blocks.filter((b) => {
|
|
87
|
+
if (b.type !== FlacBlockType.APPLICATION)
|
|
88
|
+
return true;
|
|
89
|
+
if (b.data.length < 4)
|
|
90
|
+
return true;
|
|
91
|
+
const appId = textDecode(b.data.slice(0, 4));
|
|
92
|
+
return appId !== "MAJK";
|
|
93
|
+
});
|
|
94
|
+
// Return original buffer unmodified if no signature block was found
|
|
95
|
+
if (kept.length === blocks.length) {
|
|
96
|
+
return bytes;
|
|
97
|
+
}
|
|
98
|
+
// Reconstruct metadata blocks and restore is_last bit on the final block
|
|
99
|
+
const rebuiltBlocks = kept.map((b, index) => {
|
|
100
|
+
const isLast = index === kept.length - 1;
|
|
101
|
+
const header = new Uint8Array(b.raw.slice(0, 4));
|
|
102
|
+
if (isLast) {
|
|
103
|
+
header[0] |= 0x80; // Set bit 7 to 1
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
header[0] &= 0x7f; // Clear bit 7 to 0
|
|
107
|
+
}
|
|
108
|
+
return concatBytes(header, b.data);
|
|
88
109
|
});
|
|
89
|
-
return
|
|
110
|
+
return concatBytes(FLAC_MAGIC, ...rebuiltBlocks, audioData);
|
|
90
111
|
}
|
|
91
112
|
catch {
|
|
92
113
|
return bytes;
|
|
93
114
|
}
|
|
94
115
|
}
|
|
95
|
-
// ───
|
|
96
|
-
|
|
116
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
117
|
+
_parseMetadata(bytes) {
|
|
118
|
+
if (bytes.length < 4 || textDecode(bytes.slice(0, 4)) !== "fLaC") {
|
|
119
|
+
throw new Error("Invalid FLAC magic header");
|
|
120
|
+
}
|
|
97
121
|
const blocks = [];
|
|
98
|
-
let offset = 4;
|
|
99
|
-
while (offset
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
122
|
+
let offset = 4;
|
|
123
|
+
while (offset < bytes.length) {
|
|
124
|
+
if (offset + 4 > bytes.length) {
|
|
125
|
+
throw new Error("Truncated metadata block header");
|
|
126
|
+
}
|
|
127
|
+
const headerByte = bytes[offset];
|
|
128
|
+
const isLast = (headerByte & 0x80) !== 0;
|
|
129
|
+
const type = headerByte & 0x7f;
|
|
130
|
+
// 24-bit big-endian integer reading
|
|
103
131
|
const length = (bytes[offset + 1] << 16) |
|
|
104
132
|
(bytes[offset + 2] << 8) |
|
|
105
133
|
bytes[offset + 3];
|
|
134
|
+
if (offset + 4 + length > bytes.length) {
|
|
135
|
+
throw new Error("Truncated metadata block payload");
|
|
136
|
+
}
|
|
106
137
|
const data = bytes.slice(offset + 4, offset + 4 + length);
|
|
107
|
-
|
|
138
|
+
const raw = bytes.slice(offset, offset + 4 + length);
|
|
139
|
+
blocks.push({ isLast, type, data, raw });
|
|
108
140
|
offset += 4 + length;
|
|
109
141
|
if (isLast)
|
|
110
142
|
break;
|
|
111
143
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
_serializeBlocks(blocks, originalBytes) {
|
|
115
|
-
// Find where audio data starts in original
|
|
116
|
-
let audioOffset = 4;
|
|
117
|
-
let offset = 4;
|
|
118
|
-
while (offset + 4 <= originalBytes.length) {
|
|
119
|
-
const header = originalBytes[offset];
|
|
120
|
-
const isLast = (header & 0x80) !== 0;
|
|
121
|
-
const length = (originalBytes[offset + 1] << 16) |
|
|
122
|
-
(originalBytes[offset + 2] << 8) |
|
|
123
|
-
originalBytes[offset + 3];
|
|
124
|
-
audioOffset = offset + 4 + length;
|
|
125
|
-
offset = audioOffset;
|
|
126
|
-
if (isLast)
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
const audioData = originalBytes.slice(audioOffset);
|
|
130
|
-
const serialized = [FLAC_MAGIC];
|
|
131
|
-
for (let i = 0; i < blocks.length; i++) {
|
|
132
|
-
const isLast = i === blocks.length - 1;
|
|
133
|
-
const { type, data } = blocks[i];
|
|
134
|
-
const header = (isLast ? 0x80 : 0x00) | (type & 0x7f);
|
|
135
|
-
const length = data.length;
|
|
136
|
-
const headerBytes = new Uint8Array([
|
|
137
|
-
header,
|
|
138
|
-
(length >> 16) & 0xff,
|
|
139
|
-
(length >> 8) & 0xff,
|
|
140
|
-
length & 0xff,
|
|
141
|
-
]);
|
|
142
|
-
serialized.push(concatBytes(headerBytes, data));
|
|
143
|
-
}
|
|
144
|
-
serialized.push(audioData);
|
|
145
|
-
return concatBytes(...serialized);
|
|
146
|
-
}
|
|
147
|
-
// ─── Vorbis Comment Helpers ──────────────────────────────────────────────────
|
|
148
|
-
_buildVorbisComment(signatureJson) {
|
|
149
|
-
const comments = [{ key: COMMENT_KEY, value: signatureJson }];
|
|
150
|
-
return this._serializeVorbisComment(comments);
|
|
144
|
+
const audioData = bytes.slice(offset);
|
|
145
|
+
return { blocks, audioData, endOffset: offset };
|
|
151
146
|
}
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return concatBytes(
|
|
147
|
+
_buildMetadataBlock(type, data, isLast) {
|
|
148
|
+
const header = new Uint8Array(4);
|
|
149
|
+
// Byte 0: [is_last (1 bit)][block_type (7 bits)]
|
|
150
|
+
header[0] = (isLast ? 0x80 : 0x00) | (type & 0x7f);
|
|
151
|
+
// Bytes 1–3: 24-bit big-endian payload size
|
|
152
|
+
header[1] = (data.length >> 16) & 0xff;
|
|
153
|
+
header[2] = (data.length >> 8) & 0xff;
|
|
154
|
+
header[3] = data.length & 0xff;
|
|
155
|
+
return concatBytes(header, data);
|
|
161
156
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
break;
|
|
172
|
-
const length = readUint32LE(data, offset);
|
|
173
|
-
offset += 4;
|
|
174
|
-
const comment = textDecode(data.slice(offset, offset + length));
|
|
175
|
-
offset += length;
|
|
176
|
-
const eqIdx = comment.indexOf("=");
|
|
177
|
-
if (eqIdx >= 0) {
|
|
178
|
-
comments.push({
|
|
179
|
-
key: comment.slice(0, eqIdx).toUpperCase(),
|
|
180
|
-
value: comment.slice(eqIdx + 1),
|
|
181
|
-
});
|
|
157
|
+
_validateAudioStream(audioData, expectedAudioBytes) {
|
|
158
|
+
if (audioData.length !== expectedAudioBytes)
|
|
159
|
+
return false;
|
|
160
|
+
if (audioData.length === 0)
|
|
161
|
+
return true;
|
|
162
|
+
// Validate initial audio frame sync code (14 bits: 11111111 111110xx)
|
|
163
|
+
if (audioData.length >= 2) {
|
|
164
|
+
if (audioData[0] !== 0xff || (audioData[1] & 0xfc) !== 0xf8) {
|
|
165
|
+
return false;
|
|
182
166
|
}
|
|
183
167
|
}
|
|
184
|
-
return
|
|
168
|
+
return true;
|
|
185
169
|
}
|
|
186
170
|
}
|
|
187
171
|
// Freeze static methods
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { PdfHandler } from "./pdf";
|
|
2
|
+
export { PngHandler } from "./png";
|
|
3
|
+
export { JpegHandler } from "./jpeg";
|
|
4
|
+
export { WavHandler } from "./wav";
|
|
5
|
+
export { Mp3Handler } from "./mp3";
|
|
6
|
+
export { Mp4Handler } from "./mp4";
|
|
7
|
+
export { FlacHandler } from "./flac";
|
|
8
|
+
export { MkvHandler } from "./mkv";
|
|
9
|
+
export { OfficeHandler } from "./office";
|
|
10
|
+
export { TextHandler } from "./text";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { PdfHandler } from "./pdf";
|
|
2
|
+
export { PngHandler } from "./png";
|
|
3
|
+
export { JpegHandler } from "./jpeg";
|
|
4
|
+
export { WavHandler } from "./wav";
|
|
5
|
+
export { Mp3Handler } from "./mp3";
|
|
6
|
+
export { Mp4Handler } from "./mp4";
|
|
7
|
+
export { FlacHandler } from "./flac";
|
|
8
|
+
export { MkvHandler } from "./mkv";
|
|
9
|
+
export { OfficeHandler } from "./office";
|
|
10
|
+
export { TextHandler } from "./text";
|