@majikah/majik-message 0.2.1 → 0.2.3
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/crypto/crypto-provider.d.ts +2 -1
- package/dist/core/crypto/crypto-provider.js +11 -4
- package/dist/core/database/thread/majik-message-thread.d.ts +94 -0
- package/dist/core/database/thread/majik-message-thread.js +253 -6
- package/dist/core/types.d.ts +35 -0
- package/dist/majik-message.js +2 -1
- package/package.json +2 -2
|
@@ -51,7 +51,6 @@ export declare function deriveKeyFromPassphrase(passphrase: string, salt: Uint8A
|
|
|
51
51
|
*/
|
|
52
52
|
export declare function deriveKeyFromMnemonic(mnemonic: string, salt: Uint8Array, iterations?: number, keyLen?: number): Uint8Array;
|
|
53
53
|
export declare function x25519SharedSecret(privRaw: Uint8Array, pubRaw: Uint8Array): Uint8Array;
|
|
54
|
-
export declare function sha256(input: string): string;
|
|
55
54
|
/**
|
|
56
55
|
* Derive a deterministic ML-KEM-768 keypair from a BIP-39 mnemonic seed.
|
|
57
56
|
*
|
|
@@ -116,3 +115,5 @@ export declare function mlKemEncapsulate(recipientPublicKey: Uint8Array): {
|
|
|
116
115
|
* @returns sharedSecret (32 bytes)
|
|
117
116
|
*/
|
|
118
117
|
export declare function mlKemDecapsulate(cipherText: Uint8Array, recipientSecretKey: Uint8Array): Uint8Array;
|
|
118
|
+
export declare function sha256(input: string): string;
|
|
119
|
+
export declare function sha512(input: string): string;
|
|
@@ -10,6 +10,8 @@ import { arrayToBase64 } from "../utils/utilities";
|
|
|
10
10
|
import { argon2id } from "@noble/hashes/argon2.js";
|
|
11
11
|
import { ARGON2_PARAMS } from "./constants";
|
|
12
12
|
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
|
13
|
+
import { sha3_512 } from "@noble/hashes/sha3.js";
|
|
14
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
13
15
|
export const IV_LENGTH = 12;
|
|
14
16
|
export function generateRandomBytes(len) {
|
|
15
17
|
const b = new Uint8Array(len);
|
|
@@ -113,10 +115,6 @@ export function x25519SharedSecret(privRaw, pubRaw) {
|
|
|
113
115
|
}
|
|
114
116
|
throw new Error("@stablelib/x25519: compatible API not found");
|
|
115
117
|
}
|
|
116
|
-
export function sha256(input) {
|
|
117
|
-
const hashed = hash(new TextEncoder().encode(input));
|
|
118
|
-
return arrayToBase64(hashed);
|
|
119
|
-
}
|
|
120
118
|
// ─── ML-KEM-768: Post-Quantum Key Encapsulation ───────────────────────────────
|
|
121
119
|
/**
|
|
122
120
|
* Derive a deterministic ML-KEM-768 keypair from a BIP-39 mnemonic seed.
|
|
@@ -185,3 +183,12 @@ export function mlKemEncapsulate(recipientPublicKey) {
|
|
|
185
183
|
export function mlKemDecapsulate(cipherText, recipientSecretKey) {
|
|
186
184
|
return ml_kem768.decapsulate(cipherText, recipientSecretKey);
|
|
187
185
|
}
|
|
186
|
+
// ─── Hashing ───────────────────────────────
|
|
187
|
+
export function sha256(input) {
|
|
188
|
+
const hashed = hash(new TextEncoder().encode(input));
|
|
189
|
+
return arrayToBase64(hashed);
|
|
190
|
+
}
|
|
191
|
+
export function sha512(input) {
|
|
192
|
+
const hashed = sha3_512(new TextEncoder().encode(input));
|
|
193
|
+
return bytesToHex(hashed);
|
|
194
|
+
}
|
|
@@ -62,9 +62,28 @@ export interface MajikMessageThreadJSON {
|
|
|
62
62
|
participants: string[];
|
|
63
63
|
status: ThreadStatus;
|
|
64
64
|
hash: string;
|
|
65
|
+
t_hash: string | null;
|
|
65
66
|
deletion_approvals: DeletionApproval[];
|
|
66
67
|
starred: boolean;
|
|
67
68
|
}
|
|
69
|
+
export interface MajikMessageThreadExport {
|
|
70
|
+
thread: MajikMessageThreadJSON;
|
|
71
|
+
messages: MajikMessageMailJSON[];
|
|
72
|
+
exported_at: ISODateString;
|
|
73
|
+
message_count: number;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Result shape returned by MajikMessageThread.auditThread().
|
|
77
|
+
* isValid is true only when ALL three checks pass.
|
|
78
|
+
*/
|
|
79
|
+
export interface ThreadAuditResult {
|
|
80
|
+
isValid: boolean;
|
|
81
|
+
threadValid: boolean;
|
|
82
|
+
chainValid: boolean;
|
|
83
|
+
hashValid: boolean;
|
|
84
|
+
errors: string[];
|
|
85
|
+
tamperedMailIDs: string[];
|
|
86
|
+
}
|
|
68
87
|
export declare class MajikThreadError extends Error {
|
|
69
88
|
code: string;
|
|
70
89
|
constructor(message: string, code: string);
|
|
@@ -84,6 +103,7 @@ export declare class MajikMessageThread {
|
|
|
84
103
|
private readonly _participants;
|
|
85
104
|
private _status;
|
|
86
105
|
private readonly _hash;
|
|
106
|
+
private _thash;
|
|
87
107
|
private _deletionApprovals;
|
|
88
108
|
private _starred;
|
|
89
109
|
private constructor();
|
|
@@ -95,9 +115,83 @@ export declare class MajikMessageThread {
|
|
|
95
115
|
get participants(): readonly MajikMessagePublicKey[];
|
|
96
116
|
get status(): ThreadStatus;
|
|
97
117
|
get hash(): string;
|
|
118
|
+
get threadHash(): string | null;
|
|
98
119
|
get deletionApprovals(): readonly DeletionApproval[];
|
|
99
120
|
get starred(): boolean;
|
|
121
|
+
set subject(value: string | undefined);
|
|
100
122
|
static create(userID: MajikUserID, owner: MajikMessageIdentity, participants: MajikMessagePublicKey[], metadata?: ThreadMetadata): MajikMessageThread;
|
|
123
|
+
/**
|
|
124
|
+
* Computes and stamps the t_hash for this thread.
|
|
125
|
+
*
|
|
126
|
+
* The t_hash is a SHA3-512 fingerprint of the entire thread's message history
|
|
127
|
+
* combined with the thread's own identity hash. It acts as a tamper-evident
|
|
128
|
+
* seal over the full conversation — if any message is altered, the t_hash
|
|
129
|
+
* won't match.
|
|
130
|
+
*
|
|
131
|
+
* Input string layout (joined with ":"):
|
|
132
|
+
* <thread.hash> : <mail[0].hash> : <mail[1].hash> : … : <thread.id>
|
|
133
|
+
* where mails are sorted by timestamp ascending (oldest first).
|
|
134
|
+
*
|
|
135
|
+
* Calling this with an empty array is valid — it seals a thread that has no
|
|
136
|
+
* messages yet (useful for archival of zero-message threads).
|
|
137
|
+
*
|
|
138
|
+
* @param mails - The full list of MajikMessageMailJSON for this thread.
|
|
139
|
+
* Does not need to be pre-sorted.
|
|
140
|
+
* @returns The updated thread instance (for chaining).
|
|
141
|
+
*/
|
|
142
|
+
generateThreadHash(mails: MajikMessageMailJSON[]): this;
|
|
143
|
+
/**
|
|
144
|
+
* Verifies that the stored t_hash matches a freshly computed one.
|
|
145
|
+
*
|
|
146
|
+
* @param mails - The full list of MajikMessageMailJSON for this thread.
|
|
147
|
+
* @returns true if the t_hash is valid and matches, false if t_hash not yet set.
|
|
148
|
+
* @throws ValidationError if the computed hash does not match the stored one.
|
|
149
|
+
*/
|
|
150
|
+
verifyThreadHash(mails: MajikMessageMailJSON[]): boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Performs a full forensic audit of the thread.
|
|
153
|
+
*
|
|
154
|
+
* Accepts raw MajikMessageMailJSON — instances are hydrated internally via
|
|
155
|
+
* MajikMessageMail.fromJSON so the call site only needs one array.
|
|
156
|
+
*
|
|
157
|
+
* Three checks must all pass for `isValid` to be true:
|
|
158
|
+
*
|
|
159
|
+
* 1. **threadValid** — `thread.validate()` passes (structural integrity of the
|
|
160
|
+
* thread object itself: UUID, hash, participants, deletion approvals, etc.)
|
|
161
|
+
*
|
|
162
|
+
* 2. **chainValid** — `MajikMessageMail.validateMailChain()` passes (every
|
|
163
|
+
* message hash is self-consistent and the blockchain p_hash linkage from
|
|
164
|
+
* thread → mail[0] → mail[1] → … is intact).
|
|
165
|
+
*
|
|
166
|
+
* 3. **hashValid** — `thread.verifyThreadHash()` passes (the SHA3-512 t_hash
|
|
167
|
+
* sealed over the full message list still matches). Returns false — not an
|
|
168
|
+
* error — when t_hash has not yet been generated, meaning the thread is
|
|
169
|
+
* structurally sound but still unsealed.
|
|
170
|
+
*
|
|
171
|
+
* @param thread - The thread instance to audit.
|
|
172
|
+
* @param mailJSONs - Full list of MajikMessageMailJSON for this thread.
|
|
173
|
+
* Does not need to be pre-sorted.
|
|
174
|
+
* @returns ThreadAuditResult with granular pass/fail per check plus error details.
|
|
175
|
+
*/
|
|
176
|
+
static auditThread(thread: MajikMessageThread, mailJSONs: MajikMessageMailJSON[]): ThreadAuditResult;
|
|
177
|
+
/**
|
|
178
|
+
* Exports the thread and its full message history as a self-contained snapshot.
|
|
179
|
+
*
|
|
180
|
+
* Requires that `generateThreadHash()` has already been called — the t_hash is
|
|
181
|
+
* the integrity seal over the exported payload and must be present before the
|
|
182
|
+
* export is considered authoritative. Call `generateThreadHash(mails)` first if
|
|
183
|
+
* it has not been set yet.
|
|
184
|
+
*
|
|
185
|
+
* The returned messages are sorted by timestamp ascending (oldest first) so the
|
|
186
|
+
* export is always deterministic regardless of the order they were passed in.
|
|
187
|
+
*
|
|
188
|
+
* @param mails - The full list of MajikMessageMailJSON for this thread.
|
|
189
|
+
* @returns MajikMessageThreadExport containing the sealed thread JSON, sorted
|
|
190
|
+
* messages, export timestamp, and message count.
|
|
191
|
+
* @throws OperationNotAllowedError if t_hash has not been generated yet.
|
|
192
|
+
* @throws ValidationError if any mail does not belong to this thread.
|
|
193
|
+
*/
|
|
194
|
+
exportThread(mails: MajikMessageMailJSON[]): MajikMessageThreadExport;
|
|
101
195
|
/**
|
|
102
196
|
* Stars the thread for the user
|
|
103
197
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from "uuid";
|
|
2
2
|
import { ThreadStatus } from "./enums";
|
|
3
|
-
import { sha256 } from "../../crypto/crypto-provider";
|
|
3
|
+
import { sha256, sha512 } from "../../crypto/crypto-provider";
|
|
4
|
+
import { MajikMessageMail, } from "./mail/majik-message-mail";
|
|
4
5
|
// ==================== Custom Errors ====================
|
|
5
6
|
export class MajikThreadError extends Error {
|
|
6
7
|
code;
|
|
@@ -22,6 +23,38 @@ export class OperationNotAllowedError extends MajikThreadError {
|
|
|
22
23
|
this.name = "OperationNotAllowedError";
|
|
23
24
|
}
|
|
24
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Sorts an array of MajikMessageMailJSON by timestamp (oldest → newest).
|
|
28
|
+
* Returns a new array — does not mutate the input.
|
|
29
|
+
*/
|
|
30
|
+
function sortMailsByTimestamp(mails) {
|
|
31
|
+
return [...mails].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Joins an array of strings with ":" as the delimiter.
|
|
35
|
+
*
|
|
36
|
+
* joinWithColon(["abc", "def", "ghi"]) → "abc:def:ghi"
|
|
37
|
+
*/
|
|
38
|
+
function joinWithColon(parts) {
|
|
39
|
+
return parts.join(":");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Builds the raw string fed into SHA3-512 for t_hash.
|
|
43
|
+
*
|
|
44
|
+
* Layout: <thread.hash> : <mail[0].hash> : … : <mail[n].hash> : <thread.id>
|
|
45
|
+
*
|
|
46
|
+
* @param threadHash - The thread's own SHA-256 hash
|
|
47
|
+
* @param threadID - The thread's UUID
|
|
48
|
+
* @param sortedMails - Messages pre-sorted by timestamp ascending
|
|
49
|
+
*/
|
|
50
|
+
function buildThreadHashInput(threadHash, threadID, sortedMails) {
|
|
51
|
+
const parts = [
|
|
52
|
+
threadHash,
|
|
53
|
+
...sortedMails.map((m) => m.hash),
|
|
54
|
+
threadID,
|
|
55
|
+
];
|
|
56
|
+
return joinWithColon(parts);
|
|
57
|
+
}
|
|
25
58
|
// ==================== Main Class ====================
|
|
26
59
|
export class MajikMessageThread {
|
|
27
60
|
_id;
|
|
@@ -32,10 +65,11 @@ export class MajikMessageThread {
|
|
|
32
65
|
_participants;
|
|
33
66
|
_status;
|
|
34
67
|
_hash;
|
|
68
|
+
_thash;
|
|
35
69
|
_deletionApprovals;
|
|
36
70
|
_starred;
|
|
37
71
|
// ==================== Private Constructor ====================
|
|
38
|
-
constructor(id, userID, owner, metadata, timestamp, participants, status, hash, deletionApprovals = [], starred = false) {
|
|
72
|
+
constructor(id, userID, owner, metadata, timestamp, participants, status, hash, deletionApprovals = [], starred = false, thash = null) {
|
|
39
73
|
this._id = id;
|
|
40
74
|
this._userID = userID;
|
|
41
75
|
this._owner = owner;
|
|
@@ -44,6 +78,7 @@ export class MajikMessageThread {
|
|
|
44
78
|
this._participants = participants;
|
|
45
79
|
this._status = status;
|
|
46
80
|
this._hash = hash;
|
|
81
|
+
this._thash = thash;
|
|
47
82
|
this._deletionApprovals = deletionApprovals;
|
|
48
83
|
this._starred = starred;
|
|
49
84
|
// Validate on construction
|
|
@@ -74,12 +109,18 @@ export class MajikMessageThread {
|
|
|
74
109
|
get hash() {
|
|
75
110
|
return this._hash;
|
|
76
111
|
}
|
|
112
|
+
get threadHash() {
|
|
113
|
+
return this._thash;
|
|
114
|
+
}
|
|
77
115
|
get deletionApprovals() {
|
|
78
116
|
return [...this._deletionApprovals];
|
|
79
117
|
}
|
|
80
118
|
get starred() {
|
|
81
119
|
return this._starred;
|
|
82
120
|
}
|
|
121
|
+
set subject(value) {
|
|
122
|
+
this.updateMetadata({ subject: value });
|
|
123
|
+
}
|
|
83
124
|
// ==================== Static Create Method ====================
|
|
84
125
|
static create(userID, owner, participants, metadata = {}) {
|
|
85
126
|
try {
|
|
@@ -108,7 +149,7 @@ export class MajikMessageThread {
|
|
|
108
149
|
const status = ThreadStatus.ONGOING;
|
|
109
150
|
// Generate hash
|
|
110
151
|
const hash = MajikMessageThread.generateHash(userID, timestamp, id, uniqueParticipants);
|
|
111
|
-
return new MajikMessageThread(id, userID, owner.id, metadata, timestamp, uniqueParticipants, status, hash, []);
|
|
152
|
+
return new MajikMessageThread(id, userID, owner.id, metadata, timestamp, uniqueParticipants, status, hash, [], false, null);
|
|
112
153
|
}
|
|
113
154
|
catch (error) {
|
|
114
155
|
if (error instanceof MajikThreadError) {
|
|
@@ -117,6 +158,210 @@ export class MajikMessageThread {
|
|
|
117
158
|
throw new MajikThreadError(`Failed to create MajikMessageThread: ${error instanceof Error ? error.message : "Unknown error"}`, "CREATE_FAILED");
|
|
118
159
|
}
|
|
119
160
|
}
|
|
161
|
+
// ==================== Thread Hash (t_hash) ====================
|
|
162
|
+
/**
|
|
163
|
+
* Computes and stamps the t_hash for this thread.
|
|
164
|
+
*
|
|
165
|
+
* The t_hash is a SHA3-512 fingerprint of the entire thread's message history
|
|
166
|
+
* combined with the thread's own identity hash. It acts as a tamper-evident
|
|
167
|
+
* seal over the full conversation — if any message is altered, the t_hash
|
|
168
|
+
* won't match.
|
|
169
|
+
*
|
|
170
|
+
* Input string layout (joined with ":"):
|
|
171
|
+
* <thread.hash> : <mail[0].hash> : <mail[1].hash> : … : <thread.id>
|
|
172
|
+
* where mails are sorted by timestamp ascending (oldest first).
|
|
173
|
+
*
|
|
174
|
+
* Calling this with an empty array is valid — it seals a thread that has no
|
|
175
|
+
* messages yet (useful for archival of zero-message threads).
|
|
176
|
+
*
|
|
177
|
+
* @param mails - The full list of MajikMessageMailJSON for this thread.
|
|
178
|
+
* Does not need to be pre-sorted.
|
|
179
|
+
* @returns The updated thread instance (for chaining).
|
|
180
|
+
*/
|
|
181
|
+
generateThreadHash(mails) {
|
|
182
|
+
try {
|
|
183
|
+
// ── One-time seal guard ────────────────────────────────────────────────
|
|
184
|
+
// t_hash is write-once. Once stamped it represents a finalized snapshot
|
|
185
|
+
// of the conversation. If you need to re-seal (e.g. after more messages),
|
|
186
|
+
// reconstruct the thread via fromJSON with t_hash: null first.
|
|
187
|
+
if (this._thash !== null) {
|
|
188
|
+
throw new OperationNotAllowedError("Thread hash has already been generated. t_hash is write-once and cannot be overwritten.");
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(mails)) {
|
|
191
|
+
throw new ValidationError("mails must be an array");
|
|
192
|
+
}
|
|
193
|
+
// Validate every mail belongs to this thread
|
|
194
|
+
for (const mail of mails) {
|
|
195
|
+
if (mail.thread_id !== this._id) {
|
|
196
|
+
throw new ValidationError(`Mail "${mail.id}" belongs to thread "${mail.thread_id}", not "${this._id}"`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const sorted = sortMailsByTimestamp(mails);
|
|
200
|
+
const input = buildThreadHashInput(this._hash, this._id, sorted);
|
|
201
|
+
this._thash = sha512(input);
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error instanceof MajikThreadError) {
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
throw new MajikThreadError(`Failed to generate thread hash: ${error instanceof Error ? error.message : "Unknown error"}`, "THREAD_HASH_FAILED");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Verifies that the stored t_hash matches a freshly computed one.
|
|
213
|
+
*
|
|
214
|
+
* @param mails - The full list of MajikMessageMailJSON for this thread.
|
|
215
|
+
* @returns true if the t_hash is valid and matches, false if t_hash not yet set.
|
|
216
|
+
* @throws ValidationError if the computed hash does not match the stored one.
|
|
217
|
+
*/
|
|
218
|
+
verifyThreadHash(mails) {
|
|
219
|
+
if (this._thash === null) {
|
|
220
|
+
return false; // Not yet generated — nothing to verify
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(mails)) {
|
|
223
|
+
throw new ValidationError("mails must be an array");
|
|
224
|
+
}
|
|
225
|
+
const sorted = sortMailsByTimestamp(mails);
|
|
226
|
+
const input = buildThreadHashInput(this._hash, this._id, sorted);
|
|
227
|
+
const expected = sha512(input);
|
|
228
|
+
if (this._thash !== expected) {
|
|
229
|
+
throw new ValidationError("Thread hash (t_hash) mismatch — message history integrity compromised");
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
// ==================== Full Thread Audit ====================
|
|
234
|
+
/**
|
|
235
|
+
* Performs a full forensic audit of the thread.
|
|
236
|
+
*
|
|
237
|
+
* Accepts raw MajikMessageMailJSON — instances are hydrated internally via
|
|
238
|
+
* MajikMessageMail.fromJSON so the call site only needs one array.
|
|
239
|
+
*
|
|
240
|
+
* Three checks must all pass for `isValid` to be true:
|
|
241
|
+
*
|
|
242
|
+
* 1. **threadValid** — `thread.validate()` passes (structural integrity of the
|
|
243
|
+
* thread object itself: UUID, hash, participants, deletion approvals, etc.)
|
|
244
|
+
*
|
|
245
|
+
* 2. **chainValid** — `MajikMessageMail.validateMailChain()` passes (every
|
|
246
|
+
* message hash is self-consistent and the blockchain p_hash linkage from
|
|
247
|
+
* thread → mail[0] → mail[1] → … is intact).
|
|
248
|
+
*
|
|
249
|
+
* 3. **hashValid** — `thread.verifyThreadHash()` passes (the SHA3-512 t_hash
|
|
250
|
+
* sealed over the full message list still matches). Returns false — not an
|
|
251
|
+
* error — when t_hash has not yet been generated, meaning the thread is
|
|
252
|
+
* structurally sound but still unsealed.
|
|
253
|
+
*
|
|
254
|
+
* @param thread - The thread instance to audit.
|
|
255
|
+
* @param mailJSONs - Full list of MajikMessageMailJSON for this thread.
|
|
256
|
+
* Does not need to be pre-sorted.
|
|
257
|
+
* @returns ThreadAuditResult with granular pass/fail per check plus error details.
|
|
258
|
+
*/
|
|
259
|
+
static auditThread(thread, mailJSONs) {
|
|
260
|
+
const errors = [];
|
|
261
|
+
let tamperedMailIDs = [];
|
|
262
|
+
let threadValid = false;
|
|
263
|
+
let chainValid = false;
|
|
264
|
+
let hashValid = false;
|
|
265
|
+
// ── 1. Structural thread validation ───────────────────────────────────────
|
|
266
|
+
try {
|
|
267
|
+
thread.validate();
|
|
268
|
+
threadValid = true;
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
errors.push(`Thread structure invalid: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
272
|
+
}
|
|
273
|
+
// ── 2. Hydrate JSON → instances, then run chain validation ────────────────
|
|
274
|
+
// bypassValidation=false so fromJSON still runs per-mail validation.
|
|
275
|
+
// If any individual mail fails to parse we record the error and skip the
|
|
276
|
+
// chain check rather than throwing out of the audit entirely.
|
|
277
|
+
let mailInstances = [];
|
|
278
|
+
try {
|
|
279
|
+
mailInstances = mailJSONs.map((json) => MajikMessageMail.fromJSON(json));
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
errors.push(`Failed to hydrate mail JSON: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
283
|
+
}
|
|
284
|
+
// Only run chain validation when hydration succeeded (or the list was empty)
|
|
285
|
+
if (mailInstances.length === mailJSONs.length) {
|
|
286
|
+
try {
|
|
287
|
+
const chainResult = MajikMessageMail.validateMailChain(thread, mailInstances);
|
|
288
|
+
chainValid = chainResult.isValid;
|
|
289
|
+
if (!chainResult.isValid) {
|
|
290
|
+
errors.push(...chainResult.errors);
|
|
291
|
+
tamperedMailIDs = chainResult.tamperedItems;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
errors.push(`Mail chain audit threw unexpectedly: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// ── 3. Thread hash (t_hash) verification ──────────────────────────────────
|
|
299
|
+
// At this point t_hash is guaranteed non-null (the early guard above threw
|
|
300
|
+
// otherwise). verifyThreadHash only throws on an actual mismatch.
|
|
301
|
+
try {
|
|
302
|
+
hashValid = thread.verifyThreadHash(mailJSONs);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
errors.push(`Thread hash mismatch: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
isValid: threadValid && chainValid && hashValid,
|
|
309
|
+
threadValid,
|
|
310
|
+
chainValid,
|
|
311
|
+
hashValid,
|
|
312
|
+
errors,
|
|
313
|
+
tamperedMailIDs: Array.from(new Set(tamperedMailIDs)),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// ==================== Thread Export ====================
|
|
317
|
+
/**
|
|
318
|
+
* Exports the thread and its full message history as a self-contained snapshot.
|
|
319
|
+
*
|
|
320
|
+
* Requires that `generateThreadHash()` has already been called — the t_hash is
|
|
321
|
+
* the integrity seal over the exported payload and must be present before the
|
|
322
|
+
* export is considered authoritative. Call `generateThreadHash(mails)` first if
|
|
323
|
+
* it has not been set yet.
|
|
324
|
+
*
|
|
325
|
+
* The returned messages are sorted by timestamp ascending (oldest first) so the
|
|
326
|
+
* export is always deterministic regardless of the order they were passed in.
|
|
327
|
+
*
|
|
328
|
+
* @param mails - The full list of MajikMessageMailJSON for this thread.
|
|
329
|
+
* @returns MajikMessageThreadExport containing the sealed thread JSON, sorted
|
|
330
|
+
* messages, export timestamp, and message count.
|
|
331
|
+
* @throws OperationNotAllowedError if t_hash has not been generated yet.
|
|
332
|
+
* @throws ValidationError if any mail does not belong to this thread.
|
|
333
|
+
*/
|
|
334
|
+
exportThread(mails) {
|
|
335
|
+
try {
|
|
336
|
+
// t_hash must be set — an unsealed thread cannot be exported
|
|
337
|
+
if (this._thash === null) {
|
|
338
|
+
throw new OperationNotAllowedError("Cannot export thread: t_hash has not been generated yet. " +
|
|
339
|
+
"Call generateThreadHash(mails) before exporting.");
|
|
340
|
+
}
|
|
341
|
+
if (!Array.isArray(mails)) {
|
|
342
|
+
throw new ValidationError("mails must be an array");
|
|
343
|
+
}
|
|
344
|
+
// Validate every mail belongs to this thread
|
|
345
|
+
for (const mail of mails) {
|
|
346
|
+
if (mail.thread_id !== this._id) {
|
|
347
|
+
throw new ValidationError(`Mail "${mail.id}" belongs to thread "${mail.thread_id}", not "${this._id}"`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const sortedMails = sortMailsByTimestamp(mails);
|
|
351
|
+
return {
|
|
352
|
+
thread: this.toJSON(),
|
|
353
|
+
messages: sortedMails,
|
|
354
|
+
exported_at: new Date().toISOString(),
|
|
355
|
+
message_count: sortedMails.length,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error instanceof MajikThreadError) {
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
throw new MajikThreadError(`Failed to export thread: ${error instanceof Error ? error.message : "Unknown error"}`, "EXPORT_THREAD_FAILED");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
120
365
|
// ==================== Star Management ====================
|
|
121
366
|
/**
|
|
122
367
|
* Stars the thread for the user
|
|
@@ -225,8 +470,8 @@ export class MajikMessageThread {
|
|
|
225
470
|
throw new ValidationError(`Deletion approval from non-participant: ${approval.publicKey}`);
|
|
226
471
|
}
|
|
227
472
|
// Verify approval hash
|
|
228
|
-
const
|
|
229
|
-
if (approval.approvalHash !==
|
|
473
|
+
const expectedApprovalHash = MajikMessageThread.generateApprovalHash(approval.publicKey, this._id, approval.timestamp);
|
|
474
|
+
if (approval.approvalHash !== expectedApprovalHash) {
|
|
230
475
|
throw new ValidationError(`Invalid approval hash for participant: ${approval.publicKey}`);
|
|
231
476
|
}
|
|
232
477
|
}
|
|
@@ -474,6 +719,7 @@ export class MajikMessageThread {
|
|
|
474
719
|
participants: [...this._participants],
|
|
475
720
|
status: this._status,
|
|
476
721
|
hash: this._hash,
|
|
722
|
+
t_hash: this._thash,
|
|
477
723
|
deletion_approvals: this._deletionApprovals.length > 0
|
|
478
724
|
? this._deletionApprovals.map((approval) => ({
|
|
479
725
|
...approval,
|
|
@@ -496,7 +742,7 @@ export class MajikMessageThread {
|
|
|
496
742
|
...approval,
|
|
497
743
|
timestamp: new Date(approval.timestamp),
|
|
498
744
|
}));
|
|
499
|
-
return new MajikMessageThread(data.id, data.user_id, data.owner, data.metadata, timestamp, data.participants, data.status, data.hash, deletionApprovals, data.starred);
|
|
745
|
+
return new MajikMessageThread(data.id, data.user_id, data.owner, data.metadata, timestamp, data.participants, data.status, data.hash, deletionApprovals, data.starred, data.t_hash);
|
|
500
746
|
}
|
|
501
747
|
catch (error) {
|
|
502
748
|
if (error instanceof MajikThreadError) {
|
|
@@ -581,6 +827,7 @@ export class MajikMessageThread {
|
|
|
581
827
|
participants: [...this._participants],
|
|
582
828
|
status: finalStatus,
|
|
583
829
|
hash: this._hash,
|
|
830
|
+
t_hash: this._thash,
|
|
584
831
|
deletion_approvals: this._deletionApprovals.map((approval) => ({
|
|
585
832
|
...approval,
|
|
586
833
|
timestamp: approval.timestamp,
|
package/dist/core/types.d.ts
CHANGED
|
@@ -85,6 +85,22 @@ export interface MajikKeyMetadata {
|
|
|
85
85
|
kdfVersion: number;
|
|
86
86
|
hasMlKem: boolean;
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Explicit integer compression level for Zstd, 1–22.
|
|
90
|
+
*
|
|
91
|
+
* Recommended values:
|
|
92
|
+
* - 1 → Fastest possible; still meaningfully compresses text/code.
|
|
93
|
+
* - 3 → Good speed/ratio balance for real-time paths.
|
|
94
|
+
* - 6 → Inflection point — noticeably better ratio, modest speed cost.
|
|
95
|
+
* - 9 → Strong compression; gains plateau significantly after this.
|
|
96
|
+
* - 15 → High-effort; use only for smaller, latency-insensitive uploads.
|
|
97
|
+
* - 19 → Near-maximum ratio without WASM memory pressure.
|
|
98
|
+
* - 22 → Archival-grade; not safe for files > 10 MB in WASM environments.
|
|
99
|
+
*
|
|
100
|
+
* For production use, prefer a CompressionPreset over a raw integer unless
|
|
101
|
+
* you have a specific tuning requirement.
|
|
102
|
+
*/
|
|
103
|
+
export type CompressionLevel = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22;
|
|
88
104
|
/**
|
|
89
105
|
* Options for MajikMessage.encryptFile().
|
|
90
106
|
*
|
|
@@ -137,6 +153,25 @@ export interface EncryptFileOptions {
|
|
|
137
153
|
threadMessageId?: string;
|
|
138
154
|
/** Foreign-key association with a thread. */
|
|
139
155
|
threadId?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Zstd compression level or preset for this file.
|
|
158
|
+
*
|
|
159
|
+
* Accepts either a raw integer (`CompressionLevel` 1–22) or a named
|
|
160
|
+
* `CompressionPreset` value. The level is always run through
|
|
161
|
+
* `MajikCompressor.adaptiveLevel()` before use, so it will be silently
|
|
162
|
+
* clamped downward for large files to avoid WASM out-of-memory errors.
|
|
163
|
+
*
|
|
164
|
+
* Defaults to ZSTD_MAX_LEVEL (22) when omitted — existing behaviour.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* // Raw integer
|
|
168
|
+
* compressionLevel: 9
|
|
169
|
+
*
|
|
170
|
+
* // Named preset
|
|
171
|
+
* compressionLevel: CompressionPreset.GOOD // 9
|
|
172
|
+
* compressionLevel: CompressionPreset.BALANCED // 6
|
|
173
|
+
*/
|
|
174
|
+
compressionLevel?: CompressionLevel | number;
|
|
140
175
|
}
|
|
141
176
|
/**
|
|
142
177
|
* Returned by MajikMessage.encryptFile().
|
package/dist/majik-message.js
CHANGED
|
@@ -659,7 +659,7 @@ export class MajikMessage {
|
|
|
659
659
|
* ```
|
|
660
660
|
*/
|
|
661
661
|
async encryptFile(options) {
|
|
662
|
-
const { data, context, originalName, mimeType, recipients = [], conversationId, isTemporary = false, expiresAt, bypassSizeLimit = false, chatMessageId, threadMessageId, threadId, userId, } = options;
|
|
662
|
+
const { data, context, originalName, mimeType, recipients = [], conversationId, isTemporary = false, expiresAt, bypassSizeLimit = false, chatMessageId, threadMessageId, threadId, userId, compressionLevel, } = options;
|
|
663
663
|
// ── 1. Resolve sender identity ──────────────────────────────────────────
|
|
664
664
|
// Builds MajikFileIdentity with both public + secret keys from keystore.
|
|
665
665
|
const identity = await this._resolveFileIdentity();
|
|
@@ -687,6 +687,7 @@ export class MajikMessage {
|
|
|
687
687
|
threadMessageId,
|
|
688
688
|
userId: finalUserID,
|
|
689
689
|
threadId: threadId,
|
|
690
|
+
compressionLevel,
|
|
690
691
|
});
|
|
691
692
|
// ── 4. Package the result ───────────────────────────────────────────────
|
|
692
693
|
return {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@majikah/majik-message",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"description": "Post-quantum end-to-end encryption with ML-KEM-768. Seed phrase–based accounts. Auto-expiring messages. Offline-ready. Exportable encrypted messages. Tamper-proof threads with blockchain-like integrity. Quantum-resistant messaging.",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.3",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"author": "Zelijah",
|
|
8
8
|
"main": "./dist/index.js",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"@bokuweb/zstd-wasm": "^0.0.27",
|
|
83
83
|
"@majikah/majik-envelope": "^0.0.1",
|
|
84
|
-
"@majikah/majik-file": "^0.0.
|
|
84
|
+
"@majikah/majik-file": "^0.0.14",
|
|
85
85
|
"@majikah/majik-key": "^0.1.10",
|
|
86
86
|
"@noble/hashes": "^2.0.1",
|
|
87
87
|
"@noble/post-quantum": "^0.5.4",
|