@majikah/majik-universal-id-client 0.0.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.
@@ -0,0 +1,1618 @@
1
+ /**
2
+ * MajikUniversalIdClient.ts
3
+ *
4
+ */
5
+ import { MajikKey } from "@majikah/majik-key";
6
+ import { MajikKeyStore } from "./core/crypto/keystore";
7
+ import { MajikContact, } from "./core/contacts/majik-contact";
8
+ import { MajikSignature } from "@majikah/majik-signature";
9
+ import { KEY_ALGO } from "./core/crypto/constants";
10
+ import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, base64ToUint8Array, } from "./core/utils/utilities";
11
+ import { MajikContactDirectory, } from "./core/contacts/majik-contact-directory";
12
+ import { clearAllBlobs, idbLoadBlob, idbSaveBlob, } from "./core/utils/idb-majik-system";
13
+ import { autoSaveMajikFileData, loadSavedMajikFileData, } from "./core/utils/majik-file-utils";
14
+ import { gunzipSync, gzipSync } from "fflate";
15
+ import { MajikUniversalID, } from "@majikah/majik-universal-id";
16
+ // ─── MajikUniversalIdClient ─────────────────────────────────────────────────────
17
+ export class MajikUniversalIdClient {
18
+ userProfile = "default";
19
+ _id;
20
+ _contactDirectory;
21
+ _ownAccounts = new Map();
22
+ _ownAccountsOrder = [];
23
+ _listeners = new Map();
24
+ autosaveTimer = null;
25
+ autosaveIntervalId = null;
26
+ autosaveIntervalMs = 15_000;
27
+ autosaveDebounceMs = 500;
28
+ user_data = null;
29
+ constructor(config) {
30
+ this._id = crypto.randomUUID();
31
+ this._contactDirectory =
32
+ config.contactDirectory ?? new MajikContactDirectory();
33
+ this.user_data = config.user || null;
34
+ const events = [
35
+ "create-id",
36
+ "sign",
37
+ "verify",
38
+ "unlock",
39
+ "lock",
40
+ "new-account",
41
+ "new-contact",
42
+ "removed-account",
43
+ "removed-contact",
44
+ "error",
45
+ "active-account-change",
46
+ ];
47
+ events.forEach((e) => this._listeners.set(e, []));
48
+ }
49
+ get user() {
50
+ return this.user_data;
51
+ }
52
+ set user(user) {
53
+ if (!user) {
54
+ throw new Error("User cannot be null or undefined");
55
+ }
56
+ const userValidation = user.validate();
57
+ if (!userValidation.isValid) {
58
+ throw new Error(userValidation.errors.join(", "));
59
+ }
60
+ this.user_data = user;
61
+ }
62
+ clearUser() {
63
+ this.user_data = null;
64
+ }
65
+ // ── Getters ──────────────────────────────────────────────────────────────
66
+ get id() {
67
+ return this._id;
68
+ }
69
+ // ── Account Management ────────────────────────────────────────────────────
70
+ /**
71
+ * Generate a new BIP-39 mnemonic phrase.
72
+ */
73
+ generateMnemonic(strength = 128) {
74
+ return MajikKey.generateMnemonic(strength);
75
+ }
76
+ /**
77
+ * Create a new account from a mnemonic and register it.
78
+ * The account is immediately unlocked after creation.
79
+ */
80
+ async createAccount(mnemonic, passphrase, label) {
81
+ try {
82
+ const key = await MajikKey.create(mnemonic, passphrase, label);
83
+ await MajikKeyStore.addMajikKey(key);
84
+ const contact = MajikContact.fromJSON(await key.toContact().toJSON());
85
+ this._registerOwnAccount(contact);
86
+ this._emit("new-account", contact);
87
+ return { id: key.id, fingerprint: key.fingerprint, backup: key.backup };
88
+ }
89
+ catch (err) {
90
+ this._emit("error", err, { context: "createAccount" });
91
+ throw err;
92
+ }
93
+ }
94
+ /**
95
+ * Import an account from a mnemonic-encrypted backup.
96
+ * Fully upgrades to Argon2id + ML-KEM + signing keys in one step.
97
+ */
98
+ async importAccountFromMnemonicBackup(backupBase64, mnemonic, passphrase, label) {
99
+ try {
100
+ const key = await MajikKeyStore.importFromMnemonicBackup(backupBase64, mnemonic, passphrase, label);
101
+ if (this.getOwnAccountById(key.id)) {
102
+ throw new Error("Account with the same ID already exists");
103
+ }
104
+ const contact = MajikContact.fromJSON(await key.toContact().toJSON());
105
+ this._registerOwnAccount(contact);
106
+ this._emit("new-account", contact);
107
+ return { id: key.id, fingerprint: key.fingerprint };
108
+ }
109
+ catch (err) {
110
+ this._emit("error", err, { context: "importAccountFromMnemonicBackup" });
111
+ throw err;
112
+ }
113
+ }
114
+ /**
115
+ * Export a mnemonic-encrypted backup for an account.
116
+ * The account must be unlocked.
117
+ */
118
+ async exportAccountMnemonicBackup(id, mnemonic) {
119
+ return MajikKeyStore.exportMnemonicBackup(id, mnemonic);
120
+ }
121
+ /**
122
+ * Register an already-existing MajikContact as one of this client's own
123
+ * accounts. Useful when bootstrapping from a persisted MajikMessage state.
124
+ */
125
+ addOwnAccount(account) {
126
+ this._registerOwnAccount(account);
127
+ this._emit("new-account", account);
128
+ }
129
+ /**
130
+ * Remove an own account from the instance.
131
+ * Does NOT delete it from MajikKeyStore — call MajikKeyStore.deleteIdentity()
132
+ * separately if permanent deletion is needed.
133
+ */
134
+ removeOwnAccount(id) {
135
+ if (!this._ownAccounts.has(id))
136
+ return false;
137
+ this._ownAccounts.delete(id);
138
+ const idx = this._ownAccountsOrder.indexOf(id);
139
+ if (idx > -1)
140
+ this._ownAccountsOrder.splice(idx, 1);
141
+ this._contactDirectory.removeContact(id);
142
+ this._emit("removed-account", id);
143
+ return true;
144
+ }
145
+ getOwnAccountById(id) {
146
+ return this._ownAccounts.get(id);
147
+ }
148
+ getActiveAccount() {
149
+ if (!this._ownAccountsOrder.length)
150
+ return null;
151
+ return this._ownAccounts.get(this._ownAccountsOrder[0]) ?? null;
152
+ }
153
+ isAccountActive(id) {
154
+ return !!this._ownAccounts.has(id) && this._ownAccountsOrder[0] === id;
155
+ }
156
+ setActiveAccount(id) {
157
+ if (!this._ownAccounts.has(id))
158
+ return false;
159
+ const idx = this._ownAccountsOrder.indexOf(id);
160
+ if (idx > -1)
161
+ this._ownAccountsOrder.splice(idx, 1);
162
+ this._ownAccountsOrder.unshift(id);
163
+ return true;
164
+ }
165
+ listOwnAccounts() {
166
+ return this._ownAccountsOrder
167
+ .map((id) => this._ownAccounts.get(id))
168
+ .filter((c) => !!c);
169
+ }
170
+ /**
171
+ * Unlock an account with its passphrase.
172
+ * Required before signing. Not required for verification.
173
+ */
174
+ async unlockAccount(id, passphrase) {
175
+ try {
176
+ await MajikKeyStore.unlock(id, passphrase);
177
+ this._emit("unlock", id);
178
+ }
179
+ catch (err) {
180
+ this._emit("error", err, { context: "unlockAccount", id });
181
+ throw err;
182
+ }
183
+ }
184
+ /**
185
+ * Lock an account — clears signing keys from memory.
186
+ */
187
+ lockAccount(id) {
188
+ MajikKeyStore.lock(id);
189
+ this._emit("lock", id);
190
+ }
191
+ /**
192
+ * Lock all loaded accounts.
193
+ */
194
+ lockAllAccounts() {
195
+ MajikKeyStore.lockAll();
196
+ for (const id of this._ownAccountsOrder) {
197
+ this._emit("lock", id);
198
+ }
199
+ }
200
+ /**
201
+ * Check whether an account's passphrase is correct without unlocking it.
202
+ */
203
+ async verifyPassphrase(id, passphrase) {
204
+ return MajikKeyStore.isPassphraseValid(id, passphrase);
205
+ }
206
+ /**
207
+ * Update the passphrase for an account. Re-encrypts all keys.
208
+ */
209
+ async updatePassphrase(id, currentPassphrase, newPassphrase) {
210
+ try {
211
+ await MajikKeyStore.updatePassphrase(id, currentPassphrase, newPassphrase);
212
+ }
213
+ catch (err) {
214
+ this._emit("error", err, { context: "updatePassphrase", id });
215
+ throw err;
216
+ }
217
+ }
218
+ /**
219
+ * Check whether an account has signing keys (Ed25519 + ML-DSA-87).
220
+ * Accounts created before the signing key upgrade need to be re-imported
221
+ * via importAccountFromMnemonicBackup() to gain signing capability.
222
+ */
223
+ accountHasSigningKeys(id) {
224
+ const key = MajikKeyStore.get(id);
225
+ return key?.hasSigningKeys ?? false;
226
+ }
227
+ /**
228
+ * Load all accounts persisted in MajikKeyStore into this instance.
229
+ * Call this on startup to hydrate from IDB.
230
+ */
231
+ async loadAccountsFromStore() {
232
+ try {
233
+ const keys = await MajikKeyStore.loadAll();
234
+ for (const key of keys) {
235
+ if (!this._ownAccounts.has(key.id)) {
236
+ const contact = MajikContact.fromJSON(await key.toContact().toJSON());
237
+ this._registerOwnAccount(contact);
238
+ }
239
+ }
240
+ }
241
+ catch (err) {
242
+ this._emit("error", err, { context: "loadAccountsFromStore" });
243
+ throw err;
244
+ }
245
+ }
246
+ // ── Contact Management ────────────────────────────────────────────────────
247
+ // ── Contact Management ────────────────────────────────────────────────────
248
+ getContactByID(id) {
249
+ if (!id?.trim())
250
+ throw new Error("Invalid contact ID");
251
+ return this._contactDirectory.getContact(id) ?? null;
252
+ }
253
+ async getContactByPublicKey(publicKeyBase64) {
254
+ if (!publicKeyBase64?.trim())
255
+ throw new Error("Invalid public key");
256
+ return ((await this._contactDirectory.getContactByPublicKeyBase64(publicKeyBase64)) ?? null);
257
+ }
258
+ async exportContactAsJSON(contactId) {
259
+ const contact = this._contactDirectory.getContact(contactId);
260
+ if (!contact)
261
+ return null;
262
+ let publicKeyBase64;
263
+ const anyPub = contact.publicKey;
264
+ if (anyPub?.raw instanceof Uint8Array) {
265
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
266
+ }
267
+ else {
268
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
269
+ publicKeyBase64 = arrayBufferToBase64(raw);
270
+ }
271
+ return JSON.stringify({
272
+ id: contact.id,
273
+ label: contact.meta?.label || "",
274
+ publicKey: publicKeyBase64,
275
+ fingerprint: contact.fingerprint,
276
+ mlKey: contact.mlKey,
277
+ }, null, 2);
278
+ }
279
+ async exportContactAsString(contactId) {
280
+ const contact = this._contactDirectory.getContact(contactId);
281
+ if (!contact)
282
+ return null;
283
+ const compressedString = this.exportContactCompressed(contact);
284
+ return compressedString;
285
+ }
286
+ async importContactFromJSON(jsonStr) {
287
+ try {
288
+ const data = JSON.parse(jsonStr);
289
+ if (!data.id || !data.publicKey || !data.fingerprint) {
290
+ return { success: false, message: "Invalid contact JSON" };
291
+ }
292
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
293
+ let publicKey;
294
+ try {
295
+ publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
296
+ }
297
+ catch {
298
+ publicKey = { raw: new Uint8Array(rawBuffer) };
299
+ }
300
+ this.addContact(new MajikContact({
301
+ id: data.id,
302
+ publicKey,
303
+ fingerprint: data.fingerprint,
304
+ meta: { label: data.label },
305
+ mlKey: data.mlKey,
306
+ }));
307
+ return { success: true, message: "Contact imported successfully" };
308
+ }
309
+ catch (err) {
310
+ return {
311
+ success: false,
312
+ message: err instanceof Error ? err.message : "Unknown error",
313
+ };
314
+ }
315
+ }
316
+ async importContactFromString(base64Str) {
317
+ try {
318
+ const parsedContact = await this.importContactCompressed(base64Str);
319
+ this.addContact(parsedContact);
320
+ return { success: true, message: "Contact imported successfully" };
321
+ }
322
+ catch (err) {
323
+ return {
324
+ success: false,
325
+ message: err instanceof Error ? err.message : "Unknown error",
326
+ };
327
+ }
328
+ }
329
+ async exportContactCompressed(contact) {
330
+ // Prepare JSON with raw keys
331
+ let publicKeyBase64;
332
+ const anyPub = contact.publicKey;
333
+ if (anyPub?.raw instanceof Uint8Array) {
334
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
335
+ }
336
+ else {
337
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
338
+ publicKeyBase64 = arrayBufferToBase64(raw);
339
+ }
340
+ const jsonObj = {
341
+ id: contact.id,
342
+ label: contact.meta?.label || "",
343
+ publicKey: publicKeyBase64,
344
+ fingerprint: contact.fingerprint,
345
+ mlKey: contact.mlKey,
346
+ };
347
+ const jsonStr = JSON.stringify(jsonObj);
348
+ const utf8 = new TextEncoder().encode(jsonStr);
349
+ // Compress with gzip or Brotli
350
+ const compressed = gzipSync(utf8);
351
+ // Encode for string export
352
+ return arrayToBase64(compressed);
353
+ }
354
+ async importContactCompressed(base64Str) {
355
+ const compressed = base64ToArrayBuffer(base64Str);
356
+ const decompressed = gunzipSync(new Uint8Array(compressed));
357
+ const jsonStr = new TextDecoder().decode(decompressed);
358
+ const data = JSON.parse(jsonStr);
359
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
360
+ let publicKey;
361
+ try {
362
+ publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
363
+ }
364
+ catch {
365
+ publicKey = { raw: new Uint8Array(rawBuffer) };
366
+ }
367
+ return new MajikContact({
368
+ id: data.id,
369
+ publicKey,
370
+ fingerprint: data.fingerprint,
371
+ meta: { label: data.label },
372
+ mlKey: data.mlKey,
373
+ });
374
+ }
375
+ addContact(contact) {
376
+ this._contactDirectory.addContact(contact);
377
+ this._emit("new-contact", contact);
378
+ this.scheduleAutosave();
379
+ }
380
+ removeContact(id) {
381
+ const result = this._contactDirectory.removeContact(id);
382
+ if (!result.success)
383
+ throw new Error(result.message);
384
+ this._emit("removed-contact", id);
385
+ this.scheduleAutosave();
386
+ }
387
+ getContactById(id) {
388
+ return this._contactDirectory.getContact(id) ?? null;
389
+ }
390
+ listContacts(includeOwnAccounts = false) {
391
+ const contacts = this._contactDirectory.listContacts(true);
392
+ if (includeOwnAccounts)
393
+ return contacts;
394
+ const ownIds = new Set(this.listOwnAccounts().map((a) => a.id));
395
+ return contacts.filter((c) => !ownIds.has(c.id));
396
+ }
397
+ updateContactMeta(id, meta) {
398
+ this._contactDirectory.updateContactMeta(id, meta);
399
+ }
400
+ /**
401
+ * Resolve a human-readable label for a signer ID.
402
+ * Checks own accounts first, then the contact directory.
403
+ * Returns the fingerprint truncated to 16 chars if no label is found.
404
+ */
405
+ resolveSignerLabel(signerId) {
406
+ // Check own accounts
407
+ const ownAccount = this._ownAccounts.get(signerId);
408
+ if (ownAccount?.meta?.label)
409
+ return ownAccount.meta.label;
410
+ // Check contact directory
411
+ const contact = this._contactDirectory.getContact(signerId);
412
+ if (contact?.meta?.label)
413
+ return contact.meta.label;
414
+ // Fallback to truncated fingerprint
415
+ return `${signerId.slice(0, 16)}…`;
416
+ }
417
+ // ── Signing ───────────────────────────────────────────────────────────────
418
+ /**
419
+ * Sign content with the active account.
420
+ *
421
+ * The active account must be unlocked and have signing keys.
422
+ * Use unlockAccount() first if needed.
423
+ *
424
+ * @param content - Raw bytes or UTF-8 string to sign
425
+ * @param options - Optional content type and timestamp override
426
+ * @param accountId - Override which account signs. Defaults to active account.
427
+ */
428
+ async sign(content, options, accountId) {
429
+ try {
430
+ const id = accountId ?? this.getActiveAccount()?.id;
431
+ if (!id)
432
+ throw new Error("No active account — call setActiveAccount() first");
433
+ const key = MajikKeyStore.get(id);
434
+ if (!key)
435
+ throw new Error(`Account not found in keystore: "${id}"`);
436
+ if (key.isLocked) {
437
+ throw new Error(`Account "${id}" is locked. Call unlockAccount() before signing.`);
438
+ }
439
+ if (!key.hasSigningKeys) {
440
+ throw new Error(`Account "${id}" has no signing keys. ` +
441
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
442
+ }
443
+ const signature = await MajikSignature.sign(content, key, options);
444
+ const result = {
445
+ signature,
446
+ signerId: signature.signerId,
447
+ contentHash: signature.contentHash,
448
+ timestamp: signature.timestamp,
449
+ contentType: signature.contentType,
450
+ };
451
+ this._emit("sign", result);
452
+ return result;
453
+ }
454
+ catch (err) {
455
+ this._emit("error", err, { context: "sign" });
456
+ throw err;
457
+ }
458
+ }
459
+ /**
460
+ * Sign content and immediately serialize to a base64 string.
461
+ * Convenience wrapper around sign() + serialize().
462
+ */
463
+ async signAndSerialize(content, options, accountId) {
464
+ const { signature } = await this.sign(content, options, accountId);
465
+ return signature.serialize();
466
+ }
467
+ /**
468
+ * Sign content and return the full JSON envelope.
469
+ * Convenience wrapper around sign() + toJSON().
470
+ */
471
+ async signToJSON(content, options, accountId) {
472
+ const { signature } = await this.sign(content, options, accountId);
473
+ return signature.toJSON();
474
+ }
475
+ // ── Verification ──────────────────────────────────────────────────────────
476
+ /**
477
+ * Verify a signature against content.
478
+ *
479
+ * Public keys can be supplied directly, extracted from the envelope itself,
480
+ * or resolved from a known MajikKey account or contact in the directory.
481
+ *
482
+ * No private key is needed. Safe to call on locked accounts.
483
+ *
484
+ * @param content - The original content that was signed
485
+ * @param signature - MajikSignature instance, JSON object, or base64 string
486
+ * @param publicKeys - Optional. If omitted, public keys are extracted from
487
+ * the envelope (self-reported — cross-check signerId
488
+ * against a trusted source for full security).
489
+ */
490
+ verify(content, signature, publicKeys) {
491
+ try {
492
+ // Deserialize if base64 string
493
+ const sig = typeof signature === "string"
494
+ ? MajikSignature.deserialize(signature)
495
+ : signature instanceof MajikSignature
496
+ ? signature
497
+ : MajikSignature.fromJSON(signature);
498
+ // Resolve public keys
499
+ const keys = publicKeys ??
500
+ (sig instanceof MajikSignature
501
+ ? sig.extractPublicKeys()
502
+ : MajikSignature.fromJSON(sig).extractPublicKeys());
503
+ const result = MajikSignature.verify(content, sig, keys);
504
+ const verifyResult = {
505
+ ...result,
506
+ signerLabel: result.signerId?.trim()
507
+ ? this.resolveSignerLabel(result.signerId)
508
+ : undefined,
509
+ };
510
+ this._emit("verify", verifyResult);
511
+ return verifyResult;
512
+ }
513
+ catch (err) {
514
+ this._emit("error", err, { context: "verify" });
515
+ throw err;
516
+ }
517
+ }
518
+ /**
519
+ * Verify against a specific known MajikKey account.
520
+ * Automatically extracts public keys from the key client.
521
+ * Works on locked accounts — only public key fields are used.
522
+ */
523
+ verifyWithAccount(content, signature, accountId) {
524
+ const key = MajikKeyStore.get(accountId);
525
+ if (!key)
526
+ throw new Error(`Account not found: "${accountId}"`);
527
+ if (!key.hasSigningKeys) {
528
+ throw new Error(`Account "${accountId}" has no signing public keys. ` +
529
+ `Re-import via importAccountFromMnemonicBackup() to enable verification.`);
530
+ }
531
+ const publicKeys = MajikSignature.publicKeysFromMajikKey(key);
532
+ return this.verify(content, signature, publicKeys);
533
+ }
534
+ /**
535
+ * Verify against a contact from the directory by their ID.
536
+ * Useful when you have the signer's contact card stored locally.
537
+ */
538
+ async verifyWithContact(content, signature, contactId) {
539
+ const contact = this._contactDirectory.getContact(contactId);
540
+ if (!contact)
541
+ throw new Error(`Contact not found: "${contactId}"`);
542
+ const sig = typeof signature === "string"
543
+ ? MajikSignature.deserialize(signature)
544
+ : signature instanceof MajikSignature
545
+ ? signature
546
+ : MajikSignature.fromJSON(signature);
547
+ // Cross-check: the envelope's signerId must match the contact's fingerprint
548
+ const envelopeSignerId = sig instanceof MajikSignature
549
+ ? sig.signerId
550
+ : sig.signerId;
551
+ if (envelopeSignerId !== contact.fingerprint) {
552
+ const result = {
553
+ valid: false,
554
+ signerId: envelopeSignerId,
555
+ contentHash: sig instanceof MajikSignature
556
+ ? sig.contentHash
557
+ : sig.contentHash,
558
+ timestamp: sig instanceof MajikSignature
559
+ ? sig.timestamp
560
+ : sig.timestamp,
561
+ signerLabel: this.resolveSignerLabel(envelopeSignerId),
562
+ };
563
+ this._emit("verify", result);
564
+ return result;
565
+ }
566
+ const edPublicKeyBase64 = sig instanceof MajikSignature
567
+ ? sig.signerEdPublicKey
568
+ : sig.signerEdPublicKey;
569
+ const mlDsaPublicKeyBase64 = sig instanceof MajikSignature
570
+ ? sig.signerMlDsaPublicKey
571
+ : sig.signerMlDsaPublicKey;
572
+ const publicKeys = {
573
+ signerId: contact.fingerprint,
574
+ edPublicKey: base64ToUint8Array(edPublicKeyBase64),
575
+ mlDsaPublicKey: base64ToUint8Array(mlDsaPublicKeyBase64),
576
+ };
577
+ return this.verify(content, sig, publicKeys);
578
+ }
579
+ /**
580
+ * Batch verify multiple signatures against the same content.
581
+ * Returns one VerifyResult per signature in the same order.
582
+ */
583
+ verifyBatch(content, signatures, publicKeys) {
584
+ return signatures.map((sig) => {
585
+ try {
586
+ return this.verify(content, sig, publicKeys);
587
+ }
588
+ catch (err) {
589
+ this._emit("error", err, { context: "verifyBatch" });
590
+ return {
591
+ valid: false,
592
+ signerId: "",
593
+ contentHash: "",
594
+ timestamp: "",
595
+ signerLabel: undefined,
596
+ };
597
+ }
598
+ });
599
+ }
600
+ // ── Text / Detached Signing ───────────────────────────────────────────────────
601
+ /**
602
+ * Convenience alias for signing a plain string.
603
+ *
604
+ * Identical to signContent() but accepts only strings — makes call-sites
605
+ * that deal exclusively with text cleaner (no Uint8Array overload noise).
606
+ *
607
+ * @example
608
+ * const sig = await majik.signText("Hello world", { contentType: "text/plain" });
609
+ * const b64 = sig.serialize(); // store alongside the text
610
+ */
611
+ async signText(text, options) {
612
+ if (!text?.trim())
613
+ throw new Error("signText: text must be a non-empty string");
614
+ return this.signContent(text, options);
615
+ }
616
+ /**
617
+ * Sign content and return both the MajikSignature instance and a portable
618
+ * base64-serialized string in one call.
619
+ *
620
+ * The serialized string is safe to store in a database column, embed in a
621
+ * JSON field, pass in an HTTP header, or encode in a QR code alongside the
622
+ * original content. Pass it back to verifyDetached() to verify.
623
+ *
624
+ * @example — sign a document and store the detached signature
625
+ * const { serialized } = await majik.signAndDetach(docBytes, {
626
+ * contentType: "application/pdf",
627
+ * });
628
+ * await db.insert({ doc_id, signature: serialized });
629
+ *
630
+ * @example — sign a text message
631
+ * const { signature, serialized } = await majik.signAndDetach("Hello!", {
632
+ * contentType: "text/plain",
633
+ * });
634
+ */
635
+ async signAndDetach(content, options) {
636
+ const signature = await this.signContent(content, options);
637
+ return { signature, serialized: signature.serialize() };
638
+ }
639
+ // ── Text / Detached Verification ──────────────────────────────────────────────
640
+ /**
641
+ * Verify a plain string against a MajikSignature.
642
+ *
643
+ * Accepts the signature as a MajikSignature instance, a MajikSignatureJSON
644
+ * object, or a base64-serialized string — whichever form is easiest at the
645
+ * call-site.
646
+ *
647
+ * The signer can be identified by contact ID, raw public key base64, or a
648
+ * MajikKey client. If none is provided the public keys embedded in the
649
+ * signature envelope are used (self-reported — cross-check result.signerId
650
+ * against a known contact fingerprint before trusting).
651
+ *
652
+ * @example
653
+ * const result = await majik.verifyText("Hello world", sig, {
654
+ * contactId: "contact_abc",
655
+ * });
656
+ * if (result.valid) console.log("Authentic");
657
+ */
658
+ async verifyText(text, signature, options) {
659
+ if (!text?.trim())
660
+ throw new Error("verifyText: text must be a non-empty string");
661
+ const sig = typeof signature === "string"
662
+ ? MajikSignature.deserialize(signature)
663
+ : signature;
664
+ return this.verifyContent(text, sig, options);
665
+ }
666
+ /**
667
+ * Verify content against a base64-serialized detached signature string.
668
+ *
669
+ * This is the pair to signAndDetach() — designed for call-sites that retrieve
670
+ * a stored base64 signature from a database or API and want to verify without
671
+ * importing MajikSignature themselves.
672
+ *
673
+ * The signer can be identified by contact ID, raw public key base64, or a
674
+ * MajikKey. If none is provided, self-reported keys from the envelope are used
675
+ * (see security note on verifyContent).
676
+ *
677
+ * @example
678
+ * const row = await db.findOne({ doc_id });
679
+ * const result = await majik.verifyDetached(docBytes, row.signature, {
680
+ * contactId: row.signer_contact_id,
681
+ * });
682
+ * if (result.valid) console.log("Signed by", result.signerId);
683
+ */
684
+ async verifyDetached(content, serializedSignature, options) {
685
+ if (!serializedSignature?.trim()) {
686
+ throw new Error("verifyDetached: serializedSignature must be a non-empty string");
687
+ }
688
+ let sig;
689
+ try {
690
+ sig = MajikSignature.deserialize(serializedSignature);
691
+ }
692
+ catch {
693
+ // Fallback: maybe caller passed raw JSON rather than base64
694
+ try {
695
+ sig = MajikSignature.fromJSON(serializedSignature);
696
+ }
697
+ catch {
698
+ throw new Error("verifyDetached: could not parse signature — expected a base64 " +
699
+ "string from sig.serialize() or a JSON string from sig.toJSON()");
700
+ }
701
+ }
702
+ return this.verifyContent(content, sig, options);
703
+ }
704
+ // ── Signature Serialization Helpers ──────────────────────────────────────────
705
+ /**
706
+ * Deserialize a base64 signature string into a MajikSignature client.
707
+ *
708
+ * Round-trip partner for MajikSignature.serialize() / sig.toString().
709
+ * Use when you have a stored base64 string and need to inspect or pass
710
+ * the instance to another method.
711
+ *
712
+ * Throws MajikSignatureSerializationError on malformed input.
713
+ *
714
+ * @example
715
+ * const sig = majik.deserializeSignature(storedBase64);
716
+ * console.log(sig.signerId, sig.timestamp);
717
+ */
718
+ deserializeSignature(serialized) {
719
+ if (!serialized?.trim()) {
720
+ throw new Error("deserializeSignature: input must be a non-empty string");
721
+ }
722
+ return MajikSignature.deserialize(serialized);
723
+ }
724
+ /**
725
+ * Extract lightweight metadata from a base64 or JSON signature string
726
+ * without performing cryptographic verification.
727
+ *
728
+ * Useful for displaying "Signed by X at Y" in a UI before the user
729
+ * explicitly triggers a verification step.
730
+ *
731
+ * Returns null if the string cannot be parsed as a MajikSignature.
732
+ *
733
+ * @example
734
+ * const meta = majik.getSignatureMetadata(storedSig);
735
+ * if (meta) {
736
+ * const contact = majik.getContactByID(meta.signerId);
737
+ * console.log(`Signed by ${contact?.meta?.label ?? meta.signerId} at ${meta.timestamp}`);
738
+ * }
739
+ */
740
+ getSignatureMetadata(serialized) {
741
+ if (!serialized?.trim())
742
+ return null;
743
+ try {
744
+ let sig;
745
+ try {
746
+ sig = MajikSignature.deserialize(serialized);
747
+ }
748
+ catch {
749
+ sig = MajikSignature.fromJSON(serialized);
750
+ }
751
+ return {
752
+ signerId: sig.signerId,
753
+ timestamp: sig.timestamp,
754
+ contentType: sig.contentType,
755
+ contentHash: sig.contentHash,
756
+ version: sig.version,
757
+ };
758
+ }
759
+ catch {
760
+ return null;
761
+ }
762
+ }
763
+ // ── Signing Capability Guard ──────────────────────────────────────────────────
764
+ /**
765
+ * Check whether an account has signing keys without throwing.
766
+ *
767
+ * Use this as a fast boolean guard before showing signing UI or before
768
+ * calling any sign* method — those methods throw if signing keys are absent,
769
+ * so checking first lets you degrade gracefully (e.g. hide a "Sign" button).
770
+ *
771
+ * Checks the in-memory keystore cache only — the account must be loaded.
772
+ * Returns false for unknown accounts rather than throwing.
773
+ *
774
+ * @example
775
+ * if (!majik.hasSigningCapability()) {
776
+ * showUpgradePrompt("Re-import your account to enable signing");
777
+ * return;
778
+ * }
779
+ * const sig = await majik.signText(message);
780
+ */
781
+ hasSigningCapability(accountId) {
782
+ const id = accountId ?? this.getActiveAccount()?.id;
783
+ if (!id)
784
+ return false;
785
+ const key = MajikKeyStore.get(id);
786
+ return key?.hasSigningKeys === true;
787
+ }
788
+ // ── Content & File Signing ────────────────────────────────────────────────
789
+ /**
790
+ * Sign raw bytes or a string using the active account.
791
+ *
792
+ * The active account is unlocked automatically if needed.
793
+ * This is the MajikMessage equivalent of MajikSignature.sign() — it resolves
794
+ * the signing key from the keystore so you don't have to manage it yourself.
795
+ *
796
+ * @example
797
+ * const sig = await majik.signContent(documentBytes, { contentType: "application/pdf" });
798
+ * const b64 = sig.serialize(); // store alongside the document
799
+ */
800
+ async signContent(content, options) {
801
+ const id = options?.accountId ?? this.getActiveAccount()?.id;
802
+ if (!id)
803
+ throw new Error("No active account — call setActiveAccount() first");
804
+ try {
805
+ await MajikKeyStore.ensureUnlocked(id);
806
+ const key = MajikKeyStore.get(id);
807
+ if (!key)
808
+ throw new Error(`Account not found in keystore: "${id}"`);
809
+ if (!key.hasSigningKeys) {
810
+ throw new Error(`Account "${id}" has no signing keys. ` +
811
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
812
+ }
813
+ return MajikSignature.sign(content, key, {
814
+ contentType: options?.contentType,
815
+ timestamp: options?.timestamp,
816
+ });
817
+ }
818
+ catch (err) {
819
+ this._emit("error", err, { context: "signContent" });
820
+ throw err;
821
+ }
822
+ }
823
+ /**
824
+ * Sign a file and embed the signature directly into it using the active account.
825
+ *
826
+ * Format is auto-detected from magic bytes — PDF stays PDF, WAV stays WAV, etc.
827
+ * Strips any existing signature before signing (idempotent re-signing).
828
+ * The active account is unlocked automatically if needed.
829
+ *
830
+ * @example
831
+ * const { blob: signedPdf } = await majik.signFile(pdfBlob);
832
+ * // signedPdf is a valid PDF with the signature embedded in its metadata
833
+ *
834
+ * @example — non-active account
835
+ * const { blob } = await majik.signFile(wavBlob, { accountId: "acc_xyz" });
836
+ */
837
+ async signFile(file, options) {
838
+ const id = options?.accountId ?? this.getActiveAccount()?.id;
839
+ if (!id)
840
+ throw new Error("No active account — call setActiveAccount() first");
841
+ try {
842
+ await MajikKeyStore.ensureUnlocked(id);
843
+ const key = MajikKeyStore.get(id);
844
+ if (!key)
845
+ throw new Error(`Account not found in keystore: "${id}"`);
846
+ if (!key.hasSigningKeys) {
847
+ throw new Error(`Account "${id}" has no signing keys. ` +
848
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
849
+ }
850
+ return MajikSignature.signFile(file, key, {
851
+ contentType: options?.contentType,
852
+ timestamp: options?.timestamp,
853
+ mimeType: options?.mimeType,
854
+ });
855
+ }
856
+ catch (err) {
857
+ this._emit("error", err, { context: "signFile" });
858
+ throw err;
859
+ }
860
+ }
861
+ /**
862
+ * Sign multiple file blobs with the active (or specified) account in one call.
863
+ *
864
+ * Each file is signed independently — a failure on one does not abort the
865
+ * others. Check result.error on each item to handle partial failures.
866
+ *
867
+ * The hasSigningKeys check is done once upfront before any signing begins,
868
+ * so the whole batch fails fast if the account can't sign rather than
869
+ * discovering it mid-batch.
870
+ *
871
+ * @example
872
+ * const results = await majik.batchSignFiles([
873
+ * { file: pdfBlob, contentType: "application/pdf" },
874
+ * { file: wavBlob, contentType: "audio/wav" },
875
+ * { file: mp4Blob, contentType: "video/mp4" },
876
+ * ]);
877
+ * for (const r of results) {
878
+ * if (r.error) console.error("Failed:", r.error.message);
879
+ * else await r2.put(key, await r.blob!.arrayBuffer());
880
+ * }
881
+ */
882
+ async batchSignFiles(files, options) {
883
+ const id = options?.accountId ?? this.getActiveAccount()?.id;
884
+ if (!id)
885
+ throw new Error("No active account — call setActiveAccount() first");
886
+ await MajikKeyStore.ensureUnlocked(id);
887
+ const key = MajikKeyStore.get(id);
888
+ if (!key)
889
+ throw new Error(`Account not found in keystore: "${id}"`);
890
+ if (!key.hasSigningKeys) {
891
+ throw new Error(`Account "${id}" has no signing keys. ` +
892
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
893
+ }
894
+ return Promise.all(files.map(async ({ file, contentType, timestamp, mimeType }) => {
895
+ try {
896
+ const result = await MajikSignature.signFile(file, key, {
897
+ contentType,
898
+ timestamp,
899
+ mimeType,
900
+ });
901
+ return {
902
+ blob: result.blob,
903
+ signature: result.signature,
904
+ serialized: result.signature.serialize(),
905
+ handler: result.handler,
906
+ mimeType: result.mimeType,
907
+ error: null,
908
+ };
909
+ }
910
+ catch (err) {
911
+ this._emit("error", err, { context: "batchSignFiles" });
912
+ return {
913
+ blob: null,
914
+ signature: null,
915
+ serialized: null,
916
+ handler: null,
917
+ mimeType: null,
918
+ error: err instanceof Error ? err : new Error(String(err)),
919
+ };
920
+ }
921
+ }));
922
+ }
923
+ // ── Verification ──────────────────────────────────────────────────────────
924
+ /**
925
+ * Verify raw bytes or a string against a MajikSignature.
926
+ *
927
+ * The signer can be identified by:
928
+ * - A contact ID from the contact directory
929
+ * - A raw base64 public key string (same format used in contacts)
930
+ * - A MajikKey instance directly
931
+ *
932
+ * If no signer is provided, the public keys embedded in the signature
933
+ * envelope are used (self-reported — see security note below).
934
+ *
935
+ * > ⚠️ When no signer is provided, the extracted public keys are self-reported
936
+ * > by whoever created the signature. Always cross-check `result.signerId`
937
+ * > against a known contact fingerprint before trusting the result.
938
+ *
939
+ * @example — verify against a known contact
940
+ * const result = await majik.verifyContent(docBytes, sig, { contactId: "contact_abc" });
941
+ * if (result.valid) console.log("Authentic, signed by:", result.signerId);
942
+ *
943
+ * @example — verify using embedded keys (self-reported)
944
+ * const result = await majik.verifyContent(docBytes, sig);
945
+ * // always check result.signerId matches a known fingerprint
946
+ */
947
+ async verifyContent(content, signature, options) {
948
+ try {
949
+ const publicKeys = await this._resolveSignerPublicKeys(options);
950
+ if (publicKeys) {
951
+ return MajikSignature.verify(content, signature, publicKeys);
952
+ }
953
+ // No signer provided — extract keys from envelope (self-reported)
954
+ const sig = signature instanceof MajikSignature
955
+ ? signature
956
+ : MajikSignature.fromJSON(signature);
957
+ return MajikSignature.verify(content, sig, sig.extractPublicKeys());
958
+ }
959
+ catch (err) {
960
+ this._emit("error", err, { context: "verifyContent" });
961
+ throw err;
962
+ }
963
+ }
964
+ /**
965
+ * Verify a file's embedded signature.
966
+ *
967
+ * The signer can be identified by:
968
+ * - A contact ID from the contact directory
969
+ * - A raw base64 public key string
970
+ * - A MajikKey instance directly
971
+ *
972
+ * If no signer is provided, the public keys embedded in the signature
973
+ * envelope are used (self-reported — see security note on verifyContent).
974
+ *
975
+ * @example — verify a signed PDF against a known contact
976
+ * const result = await majik.verifyFile(signedPdf, { contactId: "contact_abc" });
977
+ * if (result.valid) console.log("Verified:", result.signerId, result.timestamp);
978
+ *
979
+ * @example — check own signed file using active account
980
+ * const result = await majik.verifyFile(signedWav, {
981
+ * contactId: majik.getActiveAccount()?.id,
982
+ * });
983
+ */
984
+ async verifyFile(file, options) {
985
+ console.log("Verifying File");
986
+ try {
987
+ const publicKeys = await this._resolveSignerPublicKeys(options);
988
+ if (publicKeys) {
989
+ return MajikSignature.verifyFile(file, publicKeys, {
990
+ expectedSignerId: options?.expectedSignerId,
991
+ mimeType: options?.mimeType,
992
+ }, true);
993
+ }
994
+ // No signer provided — extract and use self-reported keys
995
+ const extracted = await MajikSignature.extractFrom(file, {
996
+ mimeType: options?.mimeType,
997
+ });
998
+ if (!extracted) {
999
+ return {
1000
+ valid: false,
1001
+ signerId: "",
1002
+ contentHash: "",
1003
+ timestamp: new Date().toISOString(),
1004
+ reason: "No embedded signature found",
1005
+ };
1006
+ }
1007
+ return MajikSignature.verifyFile(file, extracted.extractPublicKeys(), {
1008
+ expectedSignerId: options?.expectedSignerId,
1009
+ mimeType: options?.mimeType,
1010
+ }, true);
1011
+ }
1012
+ catch (err) {
1013
+ this._emit("error", err, { context: "verifyFile" });
1014
+ throw err;
1015
+ }
1016
+ }
1017
+ /**
1018
+ * Verify multiple files' embedded signatures against the same signer in
1019
+ * one call.
1020
+ *
1021
+ * Each file is verified independently — a failed verification sets
1022
+ * result.valid = false and populates result.error, it does not throw.
1023
+ *
1024
+ * @example
1025
+ * const results = await majik.batchVerifyFiles(
1026
+ * [pdfBlob, wavBlob, mp4Blob],
1027
+ * { contactId: "contact_abc" },
1028
+ * );
1029
+ * const allValid = results.every(r => r.valid);
1030
+ */
1031
+ async batchVerifyFiles(files, options) {
1032
+ // Resolve public keys once — reused across all files in the batch
1033
+ const publicKeys = await this._resolveSignerPublicKeys(options).catch(() => null);
1034
+ return Promise.all(files.map(async (entry) => {
1035
+ const { file, mimeType, expectedSignerId } = entry instanceof Blob
1036
+ ? {
1037
+ file: entry,
1038
+ mimeType: undefined,
1039
+ expectedSignerId: options?.expectedSignerId,
1040
+ }
1041
+ : {
1042
+ ...entry,
1043
+ expectedSignerId: entry.expectedSignerId ?? options?.expectedSignerId,
1044
+ };
1045
+ try {
1046
+ let result;
1047
+ if (publicKeys) {
1048
+ result = await MajikSignature.verifyFile(file, publicKeys, {
1049
+ mimeType,
1050
+ expectedSignerId,
1051
+ });
1052
+ }
1053
+ else {
1054
+ // No signer hint — use self-reported keys from each file's envelope
1055
+ const extracted = await MajikSignature.extractFrom(file, {
1056
+ mimeType,
1057
+ });
1058
+ if (!extracted) {
1059
+ return {
1060
+ valid: false,
1061
+ signerId: "",
1062
+ contentHash: "",
1063
+ timestamp: new Date().toISOString(),
1064
+ reason: "No embedded signature found",
1065
+ handler: null,
1066
+ mimeType: mimeType ?? null,
1067
+ error: null,
1068
+ };
1069
+ }
1070
+ result = await MajikSignature.verifyFile(file, extracted.extractPublicKeys(), { mimeType, expectedSignerId });
1071
+ }
1072
+ return {
1073
+ ...result,
1074
+ handler: result.handler ?? null,
1075
+ mimeType: mimeType ?? null,
1076
+ error: null,
1077
+ };
1078
+ }
1079
+ catch (err) {
1080
+ this._emit("error", err, { context: "batchVerifyFiles" });
1081
+ return {
1082
+ valid: false,
1083
+ signerId: "",
1084
+ contentHash: "",
1085
+ timestamp: new Date().toISOString(),
1086
+ handler: null,
1087
+ mimeType: mimeType ?? null,
1088
+ error: err instanceof Error ? err : new Error(String(err)),
1089
+ };
1090
+ }
1091
+ }));
1092
+ }
1093
+ // ── Signature Utilities ───────────────────────────────────────────────────
1094
+ /**
1095
+ * Extract the embedded MajikSignature from a file.
1096
+ * Returns a fully typed MajikSignature instance, or null if not found.
1097
+ *
1098
+ * Does not verify — use verifyFile() to verify.
1099
+ *
1100
+ * @example
1101
+ * const sig = await majik.extractSignature(file);
1102
+ * if (sig) console.log("Signed by:", sig.signerId, "at", sig.timestamp);
1103
+ */
1104
+ async extractSignature(file, options) {
1105
+ try {
1106
+ return MajikSignature.extractFrom(file, options);
1107
+ }
1108
+ catch (err) {
1109
+ this._emit("error", err, { context: "extractSignature" });
1110
+ throw err;
1111
+ }
1112
+ }
1113
+ /**
1114
+ * Return a clean copy of the file with any embedded signature removed.
1115
+ * The returned bytes are exactly what was originally signed.
1116
+ *
1117
+ * Useful before re-processing or re-encrypting a signed file.
1118
+ *
1119
+ * @example
1120
+ * const originalBlob = await majik.stripSignature(signedMp4);
1121
+ */
1122
+ async stripSignature(file, options) {
1123
+ try {
1124
+ return MajikSignature.stripFrom(file, options);
1125
+ }
1126
+ catch (err) {
1127
+ this._emit("error", err, { context: "stripSignature" });
1128
+ throw err;
1129
+ }
1130
+ }
1131
+ /**
1132
+ * Check whether a file contains an embedded MajikSignature.
1133
+ * Does not verify — purely a structural presence check.
1134
+ *
1135
+ * @example
1136
+ * if (await majik.isFileSigned(file)) {
1137
+ * const result = await majik.verifyFile(file, { contactId });
1138
+ * }
1139
+ */
1140
+ async isFileSigned(file, options) {
1141
+ try {
1142
+ return MajikSignature.isSigned(file, options);
1143
+ }
1144
+ catch (err) {
1145
+ this._emit("error", err, { context: "isFileSigned" });
1146
+ throw err;
1147
+ }
1148
+ }
1149
+ /**
1150
+ * Get the public keys for the active account, ready for use with
1151
+ * MajikSignature.verify() or for sharing with another party.
1152
+ *
1153
+ * Works on locked keys — only reads public fields.
1154
+ *
1155
+ * @example
1156
+ * const myKeys = await majik.getSigningPublicKeys();
1157
+ * // share myKeys with someone so they can verify your signatures
1158
+ */
1159
+ async getSigningPublicKeys(accountId) {
1160
+ const id = accountId ?? this.getActiveAccount()?.id;
1161
+ if (!id)
1162
+ throw new Error("No active account — call setActiveAccount() first");
1163
+ const key = MajikKeyStore.get(id);
1164
+ if (!key)
1165
+ throw new Error(`Account not found in keystore: "${id}"`);
1166
+ if (!key.hasSigningKeys) {
1167
+ throw new Error(`Account "${id}" has no signing keys. ` +
1168
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
1169
+ }
1170
+ return MajikSignature.publicKeysFromMajikKey(key);
1171
+ }
1172
+ /**
1173
+ * Re-sign a file blob — strips any existing embedded signature, signs
1174
+ * with the active (or specified) account, and returns the newly signed blob.
1175
+ *
1176
+ * Use after key rotation or when the signing account changes. The returned
1177
+ * blob is the same format as the input — PDF stays PDF, WAV stays WAV.
1178
+ *
1179
+ * Distinct from resignMajikFile() which operates on a MajikFile instance
1180
+ * (the encrypted .mjkb container). This operates on a plain file Blob.
1181
+ *
1182
+ * @example
1183
+ * const { blob } = await majik.resignFile(oldSignedPdf);
1184
+ * await r2.put(key, await blob.arrayBuffer());
1185
+ */
1186
+ async resignFile(file, options) {
1187
+ // signFile already strips before signing — resignFile is a named alias
1188
+ // that makes the caller's intent explicit at the call-site.
1189
+ return this.signFile(file, options);
1190
+ }
1191
+ /**
1192
+ * Extract metadata from a file's embedded signature without verifying it.
1193
+ *
1194
+ * Useful for rendering "Signed by X at Y" in a UI before the user
1195
+ * explicitly triggers a verify step, or for routing to the correct
1196
+ * contact record before calling verifyFile().
1197
+ *
1198
+ * Returns null if the file has no embedded signature or the JSON is
1199
+ * structurally malformed.
1200
+ *
1201
+ * @example
1202
+ * const info = await majik.getFileSignatureInfo(pdfBlob);
1203
+ * if (info) {
1204
+ * const contact = majik.getContactByID(info.signerId);
1205
+ * console.log(`Signed by ${contact?.meta?.label ?? info.signerId}`);
1206
+ * console.log(`Format handled by: ${info.handler}`);
1207
+ * }
1208
+ */
1209
+ async getFileSignatureInfo(file, options) {
1210
+ try {
1211
+ return MajikSignature.extractFrom(file, options);
1212
+ }
1213
+ catch (err) {
1214
+ this._emit("error", err, { context: "getFileSignatureInfo" });
1215
+ throw err;
1216
+ }
1217
+ }
1218
+ // ── STAMP (compression-resistant image signing) ───────────────────────────
1219
+ //
1220
+ // These methods delegate to MajikImageSignature, passing `MajikSignature`
1221
+ // itself as the adapter — the same pattern used by signFile → MajikSignatureEmbed.
1222
+ //
1223
+ // The adapter is typed as MajikSignatureStaticAdapter (an interface defined
1224
+ // in core/stamp/image-signature.ts) so no circular import is introduced:
1225
+ //
1226
+ // majik-signature → core/stamp/image-signature → (adapter interface only)
1227
+ //
1228
+ // ─────────────────────────────────────────────────────────────────────────
1229
+ /**
1230
+ * Sign an image with dual-layer embedding.
1231
+ *
1232
+ * Every signed image carries two independent proofs:
1233
+ *
1234
+ * Layer 1 — Pixel rows appended at the bottom (+~6px height)
1235
+ * Full MajikSignature: Ed25519 + ML-DSA-87 (post-quantum)
1236
+ * Survives: direct sharing, email attachments, Slack, internal tools
1237
+ * Stripped by: platforms that crop/resize (Gmail, LinkedIn, Facebook)
1238
+ *
1239
+ * Layer 2 — DCT coefficient steganography (invisible, no size change)
1240
+ * Ed25519-only stub + Reed-Solomon ECC (205 bytes)
1241
+ * Survives: Q70+ JPEG recompression, WebP conversion, platform uploads
1242
+ * Does not survive: screenshots, heavy crop, below-Q70 recompression
1243
+ *
1244
+ * Output is PNG by default. When uploaded to a platform, Layer 1 may be
1245
+ * stripped but Layer 2 survives — verifyStamp() handles both automatically.
1246
+ *
1247
+ * Minimum image size: 600×600px (smaller images are padded with white).
1248
+ *
1249
+ * @param image Any image format the browser supports (JPEG, PNG, WebP…)
1250
+ * @param key Unlocked MajikKey with signing keys
1251
+ * @param options Output MIME type, JPEG quality, timestamp override
1252
+ * @returns blob (signed image), stub (DCT layer metadata),
1253
+ * fullEnvelope (complete MajikSignatureJSON for Layer 1)
1254
+ *
1255
+ * @example
1256
+ * const { blob, stub } = await MajikSignature.stampImage(imageBlob, key);
1257
+ * // blob → upload or attach; visually identical to the original
1258
+ * // stub → signerId, timestamp, pHash for display
1259
+ */
1260
+ static async stampImage(image, key, options) {
1261
+ return MajikSignature.stampImage(image, key, options);
1262
+ }
1263
+ /**
1264
+ * Verify a stamped image's embedded MajikImageSignature.
1265
+ *
1266
+ * Tries both layers automatically:
1267
+ * - Both present → both must pass (maximum integrity, post-quantum proof)
1268
+ * - Pixel row only → pixel row must pass (full Ed25519 + ML-DSA-87)
1269
+ * - DCT only → DCT must pass (Ed25519 fallback, typical after platform upload)
1270
+ * - Neither → invalid
1271
+ *
1272
+ * The `layer` field in the result communicates the trust level so callers
1273
+ * can surface it in UI: 'both' > 'pixel-row' > 'dct-only'.
1274
+ *
1275
+ * @param image The image to verify — may be platform-compressed
1276
+ * @param options hammingThreshold override (default 8 — strict)
1277
+ *
1278
+ * @example
1279
+ * const result = await MajikSignature.verifyStamp(imageBlob);
1280
+ * if (result.valid) {
1281
+ * console.log(`✓ Signed by ${result.signerId}`);
1282
+ * console.log(` Verified via: ${result.layer}`);
1283
+ * // result.layer: 'both' | 'pixel-row' | 'dct-only'
1284
+ * }
1285
+ */
1286
+ static async verifyStamp(image, options) {
1287
+ return MajikSignature.verifyStamp(image, options);
1288
+ }
1289
+ /**
1290
+ * Inspect which stamp layers are present without verifying.
1291
+ *
1292
+ * Fast — useful for rendering a "Signed by X on Y" badge in a UI before
1293
+ * committing to a full cryptographic verify call.
1294
+ *
1295
+ * Does NOT confirm the signatures are valid — call verifyStamp() for that.
1296
+ *
1297
+ * @example
1298
+ * const info = await MajikSignature.inspectStamp(imageBlob);
1299
+ * if (info.hasPixelRow) console.log('Full post-quantum proof present');
1300
+ * if (info.hasDct) console.log('Compression-resistant stub present');
1301
+ * info.dctMeta?.signerId // signer ID (unverified — display only)
1302
+ * info.pixelRowMeta?.timestamp // timestamp (unverified — display only)
1303
+ */
1304
+ static async inspectStamp(image) {
1305
+ return MajikSignature.inspectStamp(image);
1306
+ }
1307
+ /**
1308
+ * Returns true if the image contains any MajikImageSignature layer.
1309
+ *
1310
+ * Does not verify — structural presence check only.
1311
+ * Use verifyStamp() to confirm the signature is cryptographically valid.
1312
+ *
1313
+ * @example
1314
+ * if (await MajikSignature.isStamped(imageBlob)) { ... }
1315
+ */
1316
+ static async isStamped(image) {
1317
+ return MajikSignature.isStamped(image);
1318
+ }
1319
+ // ── Identity / Passphrase ─────────────────────────────────────────────────
1320
+ /**
1321
+ * Ensure an identity is unlocked.
1322
+ * Delegates entirely to MajikKeyStore.ensureUnlocked() — passphrase prompting
1323
+ * is handled there via onUnlockRequested or the optional promptFn.
1324
+ */
1325
+ async ensureIdentityUnlocked(id, promptFn) {
1326
+ return MajikKeyStore.ensureUnlocked(id, promptFn);
1327
+ }
1328
+ async isPassphraseValid(passphrase, id) {
1329
+ const target = id ? this.getOwnAccountById(id) : this.getActiveAccount();
1330
+ if (!target)
1331
+ return false;
1332
+ return MajikKeyStore.isPassphraseValid(target.id, passphrase);
1333
+ }
1334
+ // ── Private: Signer resolution ────────────────────────────────────────────
1335
+ /**
1336
+ * Resolve MajikSignerPublicKeys from whichever signer hint was provided.
1337
+ * Returns null if no hint was given (caller should fall back to self-reported keys).
1338
+ *
1339
+ * Mirrors the _resolveRecipients / _resolveFileIdentity pattern used
1340
+ * throughout MajikMessage — consistent account/contact resolution in one place.
1341
+ */
1342
+ async _resolveSignerPublicKeys(options) {
1343
+ if (!options)
1344
+ return null;
1345
+ // Option A: caller passed a MajikKey instance directly
1346
+ if (options.key) {
1347
+ return MajikSignature.publicKeysFromMajikKey(options.key);
1348
+ }
1349
+ // Option B: contact ID looked up from the contact directory
1350
+ if (options.contactId) {
1351
+ const contact = this._contactDirectory.getContact(options.contactId);
1352
+ if (!contact) {
1353
+ throw new Error(`No contact found for id "${options.contactId}"`);
1354
+ }
1355
+ // Own accounts are in the keystore — get their signing keys directly
1356
+ const ownAccount = this.getOwnAccountById(options.contactId);
1357
+ if (ownAccount) {
1358
+ const key = MajikKeyStore.get(options.contactId);
1359
+ if (key?.hasSigningKeys) {
1360
+ return MajikSignature.publicKeysFromMajikKey(key);
1361
+ }
1362
+ }
1363
+ // External contact — resolve from their contact card fields
1364
+ if (!contact.edPublicKeyBase64 || !contact.mlDsaPublicKeyBase64) {
1365
+ throw new Error(`Contact "${options.contactId}" has no signing public keys. ` +
1366
+ `They may need to share an updated contact card.`);
1367
+ }
1368
+ return {
1369
+ signerId: contact.fingerprint,
1370
+ edPublicKey: base64ToUint8Array(contact.edPublicKeyBase64),
1371
+ mlDsaPublicKey: base64ToUint8Array(contact.mlDsaPublicKeyBase64),
1372
+ };
1373
+ }
1374
+ // Option C: raw base64 public key — look up via contact directory
1375
+ if (options.publicKeyBase64) {
1376
+ const contact = await this._contactDirectory.getContactByPublicKeyBase64(options.publicKeyBase64);
1377
+ if (!contact) {
1378
+ throw new Error(`No contact found for public key "${options.publicKeyBase64}"`);
1379
+ }
1380
+ if (!contact.edPublicKeyBase64 || !contact.mlDsaPublicKeyBase64) {
1381
+ throw new Error(`Contact for key "${options.publicKeyBase64}" has no signing public keys.`);
1382
+ }
1383
+ return {
1384
+ signerId: contact.fingerprint,
1385
+ edPublicKey: base64ToUint8Array(contact.edPublicKeyBase64),
1386
+ mlDsaPublicKey: base64ToUint8Array(contact.mlDsaPublicKeyBase64),
1387
+ };
1388
+ }
1389
+ return null;
1390
+ }
1391
+ // ── Serialization ─────────────────────────────────────────────────────────
1392
+ async toJSON() {
1393
+ const accounts = [];
1394
+ for (const id of this._ownAccountsOrder) {
1395
+ const acct = this._ownAccounts.get(id);
1396
+ if (acct)
1397
+ accounts.push(await acct.toJSON());
1398
+ }
1399
+ return {
1400
+ id: this._id,
1401
+ contacts: await this._contactDirectory.toJSON(),
1402
+ ownAccounts: {
1403
+ accounts,
1404
+ order: [...this._ownAccountsOrder],
1405
+ },
1406
+ };
1407
+ }
1408
+ static async fromJSON(json, config = {}) {
1409
+ const directory = config.contactDirectory ?? new MajikContactDirectory();
1410
+ if (!config.contactDirectory) {
1411
+ await directory.fromJSON(json.contacts);
1412
+ }
1413
+ const client = new this({ contactDirectory: directory });
1414
+ try {
1415
+ if (json.ownAccounts && Array.isArray(json.ownAccounts.accounts)) {
1416
+ for (const acct of json.ownAccounts.accounts) {
1417
+ try {
1418
+ const raw = base64ToArrayBuffer(acct.publicKeyBase64);
1419
+ const publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
1420
+ const contact = MajikContact.create(acct.id, publicKey, acct.fingerprint, acct.meta);
1421
+ client._ownAccounts.set(contact.id, contact);
1422
+ }
1423
+ catch (e) {
1424
+ console.info("Fallback restoring own account (raw-key wrapper)", acct.id, e);
1425
+ }
1426
+ }
1427
+ if (Array.isArray(json.ownAccounts.order)) {
1428
+ client._ownAccountsOrder = [...json.ownAccounts.order];
1429
+ }
1430
+ // Fallback: populate from contactDirectory if accounts array failed
1431
+ if (client._ownAccounts.size === 0) {
1432
+ for (const id of client._ownAccountsOrder) {
1433
+ const c = client._contactDirectory.getContact(id);
1434
+ if (c)
1435
+ client._ownAccounts.set(id, c);
1436
+ }
1437
+ }
1438
+ // Ensure own accounts are in contactDirectory
1439
+ client._ownAccountsOrder.forEach((id) => {
1440
+ const c = client._ownAccounts.get(id);
1441
+ if (c && !client._contactDirectory.hasContact(c.id)) {
1442
+ client._contactDirectory.addContact(c);
1443
+ }
1444
+ });
1445
+ }
1446
+ }
1447
+ catch (e) {
1448
+ console.warn("Error restoring ownAccounts:", e);
1449
+ }
1450
+ return client;
1451
+ }
1452
+ // ── Events ────────────────────────────────────────────────────────────────
1453
+ on(event, callback) {
1454
+ this._listeners.get(event)?.push(callback);
1455
+ }
1456
+ off(event, callback) {
1457
+ const cbs = this._listeners.get(event);
1458
+ if (!cbs?.length)
1459
+ return;
1460
+ if (callback) {
1461
+ const i = cbs.indexOf(callback);
1462
+ if (i !== -1)
1463
+ cbs.splice(i, 1);
1464
+ }
1465
+ else {
1466
+ this._listeners.set(event, []);
1467
+ }
1468
+ }
1469
+ // ── Private ───────────────────────────────────────────────────────────────
1470
+ _registerOwnAccount(contact) {
1471
+ if (!this._ownAccounts.has(contact.id)) {
1472
+ this._ownAccounts.set(contact.id, contact);
1473
+ this._ownAccountsOrder.push(contact.id);
1474
+ }
1475
+ if (!this._contactDirectory.hasContact(contact.id)) {
1476
+ this._contactDirectory.addContact(contact);
1477
+ }
1478
+ if (!this.getActiveAccount()) {
1479
+ this.setActiveAccount(contact.id);
1480
+ }
1481
+ }
1482
+ _emit(event, ...args) {
1483
+ this._listeners.get(event)?.forEach((cb) => {
1484
+ try {
1485
+ cb(...args);
1486
+ }
1487
+ catch (err) {
1488
+ console.warn(`MajikUniversalIdClient event handler error (${event}):`, err);
1489
+ }
1490
+ });
1491
+ }
1492
+ // ── Persistence ───────────────────────────────────────────────────────────
1493
+ attachAutosaveHandlers() {
1494
+ if (typeof window === "undefined")
1495
+ return;
1496
+ try {
1497
+ window.addEventListener("beforeunload", () => void this.saveState());
1498
+ }
1499
+ catch {
1500
+ /* ignore */
1501
+ }
1502
+ this.startAutosave();
1503
+ }
1504
+ startAutosave() {
1505
+ if (this.autosaveIntervalId || typeof window === "undefined")
1506
+ return;
1507
+ this.autosaveIntervalId = window.setInterval(() => void this.saveState(), this.autosaveIntervalMs);
1508
+ }
1509
+ stopAutosave() {
1510
+ if (!this.autosaveIntervalId || typeof window === "undefined")
1511
+ return;
1512
+ window.clearInterval(this.autosaveIntervalId);
1513
+ this.autosaveIntervalId = null;
1514
+ }
1515
+ scheduleAutosave() {
1516
+ if (typeof window === "undefined")
1517
+ return;
1518
+ if (this.autosaveTimer)
1519
+ window.clearTimeout(this.autosaveTimer);
1520
+ this.autosaveTimer = window.setTimeout(() => {
1521
+ void this.saveState();
1522
+ this.autosaveTimer = null;
1523
+ }, this.autosaveDebounceMs);
1524
+ }
1525
+ async saveState() {
1526
+ try {
1527
+ const json = await this.toJSON();
1528
+ await idbSaveBlob("majik-signature-state", autoSaveMajikFileData(json), this.userProfile);
1529
+ }
1530
+ catch (err) {
1531
+ console.error("Failed to save MajikUniversalIdClient state:", err);
1532
+ }
1533
+ }
1534
+ async loadState() {
1535
+ try {
1536
+ const saved = await idbLoadBlob("majik-signature-state", this.userProfile);
1537
+ if (!saved?.data)
1538
+ return;
1539
+ const loaded = await loadSavedMajikFileData(saved.data);
1540
+ const restored = await MajikUniversalIdClient.fromJSON(loaded.j);
1541
+ this._contactDirectory = restored._contactDirectory;
1542
+ this._ownAccounts = restored._ownAccounts;
1543
+ this._ownAccountsOrder = [...restored._ownAccountsOrder];
1544
+ }
1545
+ catch (err) {
1546
+ console.error("Failed to load MajikUniversalIdClient state:", err);
1547
+ }
1548
+ }
1549
+ static async loadOrCreate(config, userProfile = "default") {
1550
+ try {
1551
+ const saved = await idbLoadBlob("majik-signature-state", userProfile);
1552
+ if (saved?.data) {
1553
+ const loaded = await loadSavedMajikFileData(saved.data);
1554
+ const instance = (await this.fromJSON(loaded.j));
1555
+ instance.attachAutosaveHandlers();
1556
+ return instance;
1557
+ }
1558
+ }
1559
+ catch (err) {
1560
+ console.warn("Error loading saved MajikUniversalIdClient state:", err);
1561
+ }
1562
+ const created = new this(config);
1563
+ await created.saveState();
1564
+ created.attachAutosaveHandlers();
1565
+ return created;
1566
+ }
1567
+ async resetData(userProfile = "default") {
1568
+ try {
1569
+ for (const id of [...this._ownAccountsOrder]) {
1570
+ await MajikKeyStore.deleteIdentity(id).catch(() => { });
1571
+ }
1572
+ this._ownAccounts.clear();
1573
+ this._ownAccountsOrder = [];
1574
+ try {
1575
+ this._contactDirectory.clear();
1576
+ }
1577
+ catch {
1578
+ /* ignore */
1579
+ }
1580
+ try {
1581
+ await MajikKeyStore.deleteAll();
1582
+ }
1583
+ catch {
1584
+ /* ignore */
1585
+ }
1586
+ try {
1587
+ await clearAllBlobs(userProfile);
1588
+ }
1589
+ catch {
1590
+ /* ignore */
1591
+ }
1592
+ this.stopAutosave();
1593
+ this.startAutosave();
1594
+ this._emit("active-account-change", null);
1595
+ }
1596
+ catch (err) {
1597
+ throw new Error(`Failed to reset data: ${err instanceof Error ? err.message : err}`);
1598
+ }
1599
+ }
1600
+ /**
1601
+ * Create a new MajikUniversalID from a MajikUser and an unlocked MajikKey.
1602
+ *
1603
+ * The key must be unlocked and have all key fields: edPublicKey, mlDsaPublicKey,
1604
+ * mlKemPublicKey (for encryption), and mlKemSecretKey is not needed here —
1605
+ * only the public key is used during creation.
1606
+ *
1607
+ * Private personal info is immediately encrypted with the bound key's
1608
+ * ML-KEM-768 public key. The rehydrated value is kept in-memory so
1609
+ * privateInfo is accessible right after create() without a separate call.
1610
+ *
1611
+ * The identity starts at IDTier.UNVERIFIED.
1612
+ */
1613
+ async createUniversalID(user, key, options) {
1614
+ const createdID = MajikUniversalID.create(user, key, options);
1615
+ this._emit("create-id", createdID);
1616
+ return createdID;
1617
+ }
1618
+ }