@majikah/majik-message 0.2.3 → 0.2.4

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.
@@ -14,6 +14,7 @@ import { MajikMessageChat } from "./core/database/chat/majik-message-chat";
14
14
  import { MajikKey } from "@majikah/majik-key";
15
15
  import { MajikEnvelope, } from "@majikah/majik-envelope";
16
16
  import { MajikFile, MajikFileError, } from "@majikah/majik-file";
17
+ import { MajikSignature, } from "@majikah/majik-signature";
17
18
  import { gzipSync, gunzipSync } from "fflate";
18
19
  // ─── MajikMessage ─────────────────────────────────────────────────────────────
19
20
  export class MajikMessage {
@@ -195,6 +196,7 @@ export class MajikMessage {
195
196
  const keyContact = key.toContact();
196
197
  const contactJSON = await keyContact.toJSON();
197
198
  const reParsedContact = MajikContact.fromJSON(contactJSON);
199
+ console.log("Account: ", key);
198
200
  this.addOwnAccount(reParsedContact);
199
201
  return { id: key.id, fingerprint: key.fingerprint };
200
202
  }
@@ -628,13 +630,14 @@ export class MajikMessage {
628
630
  // ── File Encryption / Decryption ──────────────────────────────────────────
629
631
  /**
630
632
  * Encrypt a binary file and return everything the caller needs to persist it.
631
-
632
- * @throws Error if no active account, account has no ML-KEM keys, or a
633
- * recipient cannot be resolved from the contact directory.
634
- * @throws MajikFileError on validation failures or crypto errors (re-thrown
635
- * from MajikFile.create() so the caller gets typed errors).
633
+ * Automatically signs the encrypted .mjkb binary using the active account's
634
+ * signing keys if available. Falls back to unsigned encryption for legacy
635
+ * accounts that pre-date signing key support.
636
636
  *
637
- * @example self-encrypted user upload
637
+ * @throws Error if no active account or a recipient cannot be resolved.
638
+ * @throws MajikFileError on validation or crypto failures (typed, re-thrown).
639
+ *
640
+ * @example — self-encrypted user upload, auto-signed
638
641
  * ```ts
639
642
  * const result = await majik.encryptFile({
640
643
  * data: fileBytes,
@@ -643,36 +646,27 @@ export class MajikMessage {
643
646
  * });
644
647
  * await r2.put(result.metadata.r2_key, result.binary);
645
648
  * await supabase.from("majik_files").insert(result.metadata);
646
- * ```
647
- *
648
- * @example — group chat image
649
- * ```ts
650
- * const result = await majik.encryptFile({
651
- * data: imageBytes,
652
- * context: "chat_image",
653
- * originalName: "photo.png",
654
- * conversationId: "conv_abc123",
655
- * recipientIds: ["contact_id_1", "contact_id_2"],
656
- * isTemporary: true,
657
- * expiresAt: MajikFile.buildExpiryDate(15),
658
- * });
649
+ * // result.metadata.signature is populated if the account has signing keys
659
650
  * ```
660
651
  */
661
652
  async encryptFile(options) {
662
653
  const { data, context, originalName, mimeType, recipients = [], conversationId, isTemporary = false, expiresAt, bypassSizeLimit = false, chatMessageId, threadMessageId, threadId, userId, compressionLevel, } = options;
663
654
  // ── 1. Resolve sender identity ──────────────────────────────────────────
664
- // Builds MajikFileIdentity with both public + secret keys from keystore.
665
655
  const identity = await this._resolveFileIdentity();
666
656
  const finalUserID = userId ?? identity.publicKey;
667
657
  // ── 2. Resolve additional recipients ───────────────────────────────────
668
- // MajikFile.create() will silently drop the sender's own fingerprint if
669
- // it appears in this list, and will deduplicate any repeated entries.
670
- // An empty list → single-recipient (self-encrypted) file.
671
658
  const recipientPubKeys = recipients.length > 0
672
659
  ? await this._resolveFileRecipientsByPublicKey(recipients)
673
660
  : [];
674
- // ── 3. Delegate to MajikFile.create() ──────────────────────────────────
675
- const file = await MajikFile.create({
661
+ // ── 3. Get the MajikKey for signing ────────────────────────────────────
662
+ // After _resolveFileIdentity() calls ensureUnlocked(), the key is
663
+ // guaranteed to be in the memory cache — get() is safe here (sync).
664
+ const activeId = this.getActiveAccount()?.id;
665
+ if (!activeId)
666
+ throw new Error("No active account — call setActiveAccount() first");
667
+ const signingKey = MajikKeyStore.get(activeId);
668
+ // ── 4. Build CreateOptions ─────────────────────────────────────────────
669
+ const createOptions = {
676
670
  data,
677
671
  identity,
678
672
  context,
@@ -686,60 +680,68 @@ export class MajikMessage {
686
680
  chatMessageId,
687
681
  threadMessageId,
688
682
  userId: finalUserID,
689
- threadId: threadId,
683
+ threadId,
690
684
  compressionLevel,
691
- });
692
- // ── 4. Package the result ───────────────────────────────────────────────
685
+ };
686
+ // ── 5. Encrypt (+ sign if signing keys are present) ────────────────────
687
+ // Accounts imported before ML-DSA signing key support won't have
688
+ // hasSigningKeys. We fall back to unsigned create() so the upload never
689
+ // fails for legacy accounts — the file is encrypted but not signed.
690
+ let file;
691
+ if (signingKey?.hasSigningKeys) {
692
+ file = await MajikFile.createAndSign(createOptions, signingKey, {
693
+ // Carry the MIME type into the signature envelope's contentType field
694
+ // so verifiers see a human-readable format label (e.g. "application/pdf").
695
+ contentType: mimeType ??
696
+ (originalName
697
+ ? (MajikFile.inferMimeType(originalName) ?? undefined)
698
+ : undefined),
699
+ });
700
+ }
701
+ else {
702
+ file = await MajikFile.create(createOptions);
703
+ }
693
704
  return {
694
705
  file,
695
706
  metadata: file.toJSON(),
696
707
  binary: file.toMJKB(),
708
+ signedBinary: file.toSignedMJKB(),
697
709
  };
698
710
  }
699
711
  /**
700
712
  * Decrypt a .mjkb binary and return the original raw bytes.
701
713
  *
714
+ * When `metadata` is provided, the signature field is automatically
715
+ * threaded through so callers receive the deserialized MajikSignature
716
+ * in the result without any extra work. Verify it with verifyMajikFile()
717
+ * or MajikSignature.verify() after decryption.
718
+ *
702
719
  * Flow:
703
720
  * 1. If `accountId` is provided, that account is tried first.
704
- * Otherwise the active account is tried first.
705
- * 2. For group files (multiple recipients), if the first account fails,
706
- * every own account is tried in sequence until one succeeds.
707
- * This mirrors the behaviour of decryptEnvelope() for group messages.
708
- * 3. Delegates to MajikFile.decrypt() — which handles:
709
- * • .mjkb binary parsing and magic-byte validation
710
- * • Single vs group payload discrimination
711
- * • ML-KEM decapsulation
712
- * • AES-256-GCM decryption
713
- * • Zstd decompression (if the file was compressed)
721
+ * 2. For group files, every own account is tried in sequence.
722
+ * 3. Delegates to MajikFile.decryptWithMetadata() for binary parsing,
723
+ * ML-KEM decapsulation, AES-256-GCM decryption, and decompression.
714
724
  *
715
- * @returns Raw plaintext bytes — the original file content before encryption.
725
+ * @returns Raw plaintext bytes, original filename, MIME type, and
726
+ * deserialized MajikSignature (null if unsigned or no metadata).
716
727
  *
717
728
  * @throws Error if no own account can decrypt the file.
718
- * @throws MajikFileError (re-thrown) on corrupt binary, wrong key, or format
719
- * errors — callers can import MajikFileError for typed catch blocks.
729
+ * @throws MajikFileError on corrupt binary, wrong key, or format errors.
720
730
  *
721
- * @example — basic usage
731
+ * @example — basic usage with metadata row from Supabase
722
732
  * ```ts
723
- * const mjkbBlob = await r2.get(metadata.r2_key);
724
- * const rawBytes = await majik.decryptFile({ source: mjkbBlob });
725
- * const url = URL.createObjectURL(new Blob([rawBytes], { type: metadata.mime_type }));
726
- * ```
727
- *
728
- * @example — explicit account (e.g. non-active account in a multi-account UI)
729
- * ```ts
730
- * const rawBytes = await majik.decryptFile({
731
- * source: mjkbBytes,
732
- * accountId: "acc_xyz",
733
+ * const mjkbBlob = await r2.get(row.r2_key);
734
+ * const { bytes, mimeType, signature } = await majik.decryptFile({
735
+ * source: mjkbBlob,
736
+ * metadata: row,
733
737
  * });
738
+ * if (signature) {
739
+ * const result = await majik.verifyMajikFile(file, { contactId: row.user_id });
740
+ * }
734
741
  * ```
735
742
  */
736
743
  async decryptFile(options) {
737
- const { source, accountId } = options;
738
- // Build a prioritised list of own accounts to try.
739
- // If an explicit accountId was requested, put that account first so it is
740
- // tried before falling back to the full list — saves unnecessary work for
741
- // single-recipient files and the common case where the caller knows which
742
- // account holds the key.
744
+ const { source, accountId, metadata } = options;
743
745
  const allAccounts = this.listOwnAccounts();
744
746
  const orderedAccounts = [];
745
747
  if (accountId) {
@@ -748,7 +750,6 @@ export class MajikMessage {
748
750
  throw new Error(`Account not found: "${accountId}"`);
749
751
  orderedAccounts.push(preferred);
750
752
  }
751
- // Append any remaining accounts not already in the list
752
753
  for (const account of allAccounts) {
753
754
  if (!orderedAccounts.some((a) => a.id === account.id)) {
754
755
  orderedAccounts.push(account);
@@ -760,20 +761,13 @@ export class MajikMessage {
760
761
  let lastError;
761
762
  for (const account of orderedAccounts) {
762
763
  try {
763
- // Resolve the secret key for this account.
764
- // _resolveFileIdentity() calls ensureUnlocked() internally, so the
765
- // keystore will prompt for a passphrase if the account is locked.
766
764
  const identity = await this._resolveFileIdentity(account.id);
767
- const { bytes: rawBytes, originalName, mimeType, } = await MajikFile.decryptWithMetadata(source, {
765
+ return await MajikFile.decryptWithMetadata(source, {
768
766
  fingerprint: identity.fingerprint,
769
767
  mlKemSecretKey: identity.mlKemSecretKey,
770
- });
771
- return { bytes: rawBytes, originalName, mimeType };
768
+ }, metadata?.signature ?? null);
772
769
  }
773
770
  catch (err) {
774
- // MajikFileError.decryptionFailed means the key didn't match — keep
775
- // trying. Any other error (corrupt binary, format error) is terminal
776
- // and re-thrown immediately so the caller gets an accurate diagnosis.
777
771
  if (err instanceof MajikFileError && err.code === "DECRYPTION_FAILED") {
778
772
  lastError = err;
779
773
  continue;
@@ -781,11 +775,267 @@ export class MajikMessage {
781
775
  throw err;
782
776
  }
783
777
  }
784
- // None of the own accounts could decrypt the file
785
778
  throw new Error(`None of your accounts can decrypt this file. ` +
786
779
  `It may have been encrypted for different recipients. ` +
787
780
  `Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
788
781
  }
782
+ // ── MajikFile Signature Methods ───────────────────────────────────────────
783
+ /**
784
+ * Sign an already-created MajikFile using the active (or specified) account
785
+ * and attach the signature to the instance.
786
+ *
787
+ * Use this for deferred signing — when a file was created via create() and
788
+ * signing happens on a second pass (e.g. after user confirmation in the UI).
789
+ * For create + sign in one call, use encryptFile() which calls createAndSign().
790
+ *
791
+ * The file's binary must be loaded (_binary !== null).
792
+ * Call file.toJSON() and persist to Supabase after signing to save the signature.
793
+ *
794
+ * @example
795
+ * await majik.signMajikFile(file);
796
+ * await supabase
797
+ * .from("majik_files")
798
+ * .update({ signature: file.signatureRaw, last_update: file.lastUpdate })
799
+ * .eq("id", file.id);
800
+ */
801
+ async signMajikFile(file, options) {
802
+ const id = options?.accountId ?? this.getActiveAccount()?.id;
803
+ if (!id)
804
+ throw new Error("No active account — call setActiveAccount() first");
805
+ try {
806
+ await MajikKeyStore.ensureUnlocked(id);
807
+ // get() is safe after ensureUnlocked() — key is in the memory cache.
808
+ const key = MajikKeyStore.get(id);
809
+ if (!key)
810
+ throw new Error(`Account not found in keystore: "${id}"`);
811
+ if (!key.hasSigningKeys) {
812
+ throw new Error(`Account "${id}" has no signing keys. ` +
813
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
814
+ }
815
+ return file.sign(key, {
816
+ contentType: options?.contentType,
817
+ timestamp: options?.timestamp,
818
+ });
819
+ }
820
+ catch (err) {
821
+ this.emit("error", err, { context: "signMajikFile" });
822
+ throw err;
823
+ }
824
+ }
825
+ /**
826
+ * Verify the signature attached to a MajikFile.
827
+ *
828
+ * The file's binary must be loaded — call file.attachBinary(r2Bytes) first
829
+ * if the instance was restored from a metadata-only Supabase row.
830
+ *
831
+ * Signer resolution:
832
+ * - contactId: looked up in the contact directory (own accounts included)
833
+ * - publicKeyBase64: looked up via contact directory
834
+ * - key: used directly (skips directory lookup)
835
+ * - none provided: falls back to public keys embedded in the signature
836
+ * envelope (self-reported — always cross-check result.signerId)
837
+ *
838
+ * Returns null if the file has no signature.
839
+ *
840
+ * @example — verify against the file's owner contact
841
+ * file.attachBinary(await r2.get(row.r2_key).arrayBuffer());
842
+ * const result = await majik.verifyMajikFile(file, {
843
+ * contactId: ownerContactId,
844
+ * });
845
+ * if (result?.valid) console.log("Verified, signed by", result.signerId);
846
+ */
847
+ async verifyMajikFile(file, options) {
848
+ if (!file.isSigned)
849
+ return null;
850
+ try {
851
+ const publicKeys = await this._resolveSignerPublicKeys(options);
852
+ if (publicKeys) {
853
+ return file.verify(publicKeys);
854
+ }
855
+ // No signer hint — use self-reported keys from the envelope.
856
+ // Caller is responsible for checking result.signerId against a trusted source.
857
+ const sig = file.signature;
858
+ if (!sig)
859
+ return null;
860
+ return file.verify(sig.extractPublicKeys());
861
+ }
862
+ catch (err) {
863
+ this.emit("error", err, { context: "verifyMajikFile" });
864
+ throw err;
865
+ }
866
+ }
867
+ /**
868
+ * Full binary verification of a MajikFile — decrypts first, then verifies
869
+ * the signature against the recovered plaintext bytes.
870
+ *
871
+ * Stronger than verifyMajikFile() because it proves both:
872
+ * 1. The ciphertext decrypts correctly (AES-GCM auth tag passes)
873
+ * 2. The plaintext matches what the signer originally signed
874
+ *
875
+ * Requires both a decryption identity (own account) and the signer's
876
+ * public keys. The binary must be loaded.
877
+ *
878
+ * @param decryptAccountId Which own account to use for decryption.
879
+ * Defaults to the active account.
880
+ *
881
+ * @example
882
+ * const result = await majik.verifyMajikFileBinary(file, {
883
+ * contactId: "contact_abc",
884
+ * });
885
+ * if (result.valid) console.log("Plaintext verified");
886
+ */
887
+ async verifyMajikFileBinary(file, options) {
888
+ if (!file.isSigned) {
889
+ throw new Error("verifyMajikFileBinary: this file has no attached signature");
890
+ }
891
+ const decryptId = options?.decryptAccountId ?? this.getActiveAccount()?.id;
892
+ if (!decryptId)
893
+ throw new Error("No active account — call setActiveAccount() first");
894
+ try {
895
+ const identity = await this._resolveFileIdentity(decryptId);
896
+ const decryptIdentity = {
897
+ fingerprint: identity.fingerprint,
898
+ mlKemSecretKey: identity.mlKemSecretKey,
899
+ };
900
+ const publicKeys = await this._resolveSignerPublicKeys(options);
901
+ if (publicKeys) {
902
+ return file.verifyBinary(decryptIdentity, publicKeys);
903
+ }
904
+ // Fall back to self-reported keys from the envelope
905
+ const sig = file.signature;
906
+ if (!sig) {
907
+ throw new Error("verifyMajikFileBinary: signature could not be deserialized");
908
+ }
909
+ return file.verifyBinary(decryptIdentity, sig.extractPublicKeys());
910
+ }
911
+ catch (err) {
912
+ this.emit("error", err, { context: "verifyMajikFileBinary" });
913
+ throw err;
914
+ }
915
+ }
916
+ /**
917
+ * Check whether the active (or specified) account is the signer of a
918
+ * MajikFile by comparing fingerprints.
919
+ *
920
+ * This is a fast, synchronous fingerprint comparison — it does NOT
921
+ * cryptographically verify the signature. Use verifyMajikFile() for proof.
922
+ *
923
+ * Useful for gating UI actions:
924
+ * - Show "Re-sign" button only if the active user is the signer
925
+ * - Show "Signed by you" vs "Signed by [contact]" labels
926
+ *
927
+ * @returns true if the account's fingerprint matches the envelope's signerId.
928
+ * false if the file is unsigned, the account has no signing keys,
929
+ * the account is not in the keystore memory cache, or fingerprints
930
+ * don't match.
931
+ *
932
+ * @example
933
+ * if (majik.isActiveAccountSigner(file)) {
934
+ * showResignButton();
935
+ * }
936
+ */
937
+ isActiveAccountSigner(file, accountId) {
938
+ const id = accountId ?? this.getActiveAccount()?.id;
939
+ if (!id)
940
+ return false;
941
+ const sigInfo = file.getSignatureInfo();
942
+ if (!sigInfo)
943
+ return false;
944
+ // get() checks the memory cache — no async needed since the account
945
+ // must already be loaded to be the active account.
946
+ const key = MajikKeyStore.get(id);
947
+ if (!key)
948
+ return false;
949
+ return key.fingerprint === sigInfo.signerId;
950
+ }
951
+ /**
952
+ * Return a rich metadata object describing who signed a MajikFile,
953
+ * without performing cryptographic verification.
954
+ *
955
+ * Combines getSignatureInfo() with a contact directory and keystore lookup
956
+ * so the UI can show a human-readable label (e.g. "Signed by Alice") instead
957
+ * of a raw fingerprint, and can distinguish own-account signatures from
958
+ * external ones.
959
+ *
960
+ * Synchronous — reads only local state. Call verifyMajikFile() separately
961
+ * if cryptographic proof is required.
962
+ *
963
+ * @returns null if the file is unsigned or the signature is malformed.
964
+ *
965
+ * @example
966
+ * const info = majik.getMajikFileSignerInfo(file);
967
+ * if (info) {
968
+ * console.log(info.isOwnAccount ? "Signed by you" : `Signed by ${info.signerLabel}`);
969
+ * console.log("at", info.timestamp);
970
+ * }
971
+ */
972
+ getMajikFileSignerInfo(file) {
973
+ const info = file.getSignatureInfo();
974
+ if (!info)
975
+ return null;
976
+ // Scan all contacts (including own accounts) for a fingerprint match.
977
+ // listContacts(true) returns own accounts + external contacts.
978
+ const allContacts = this.listContacts(true);
979
+ const contact = allContacts.find((c) => c.fingerprint === info.signerId);
980
+ const isOwnAccount = this.listOwnAccounts().some((a) => a.fingerprint === info.signerId);
981
+ return {
982
+ ...info,
983
+ signerLabel: contact?.meta?.label ?? null,
984
+ isOwnAccount,
985
+ isKnownContact: contact !== undefined,
986
+ };
987
+ }
988
+ /**
989
+ * Remove the signature from a MajikFile and persist the change.
990
+ *
991
+ * A convenience wrapper around file.removeSignature() that handles the
992
+ * Supabase update in one call. Useful for admin flows or when re-signing
993
+ * after a file mutation.
994
+ *
995
+ * Unlike file.removeSignature() which only mutates the in-memory instance,
996
+ * this method also returns the updated metadata row ready for upsert.
997
+ *
998
+ * Note: removing a signature does not re-encrypt or modify the R2 binary —
999
+ * only the Supabase metadata row changes.
1000
+ *
1001
+ * @returns The updated MajikFileJSON with signature: null.
1002
+ *
1003
+ * @example
1004
+ * const updatedRow = majik.unsignMajikFile(file);
1005
+ * await supabase
1006
+ * .from("majik_files")
1007
+ * .update({ signature: null, last_update: updatedRow.last_update })
1008
+ * .eq("id", file.id);
1009
+ */
1010
+ unsignMajikFile(file) {
1011
+ file.removeSignature();
1012
+ return file.toJSON();
1013
+ }
1014
+ /**
1015
+ * Re-sign a MajikFile — removes any existing signature, then signs
1016
+ * with the active (or specified) account.
1017
+ *
1018
+ * Idempotent: calling this multiple times always produces a fresh signature
1019
+ * from the specified account. Useful after a contact label change or when
1020
+ * rotating signing keys.
1021
+ *
1022
+ * The file's binary must be loaded. Call file.attachBinary() first if needed.
1023
+ * Persist with file.toJSON() after calling this method.
1024
+ *
1025
+ * @returns The new MajikSignature.
1026
+ *
1027
+ * @example
1028
+ * file.attachBinary(await r2.get(row.r2_key).arrayBuffer());
1029
+ * const sig = await majik.resignMajikFile(file);
1030
+ * await supabase
1031
+ * .from("majik_files")
1032
+ * .update({ signature: file.signatureRaw, last_update: file.lastUpdate })
1033
+ * .eq("id", file.id);
1034
+ */
1035
+ async resignMajikFile(file, options) {
1036
+ file.removeSignature();
1037
+ return this.signMajikFile(file, options);
1038
+ }
789
1039
  // ── Envelope Cache ────────────────────────────────────────────────────────
790
1040
  async listCachedEnvelopes(offset = 0, limit = 50) {
791
1041
  return this.envelopeCache.listRecent(offset, limit);
@@ -890,6 +1140,308 @@ export class MajikMessage {
890
1140
  }
891
1141
  }
892
1142
  }
1143
+ // ── Content & File Signing ────────────────────────────────────────────────
1144
+ /**
1145
+ * Sign raw bytes or a string using the active account.
1146
+ *
1147
+ * The active account is unlocked automatically if needed.
1148
+ * This is the MajikMessage equivalent of MajikSignature.sign() — it resolves
1149
+ * the signing key from the keystore so you don't have to manage it yourself.
1150
+ *
1151
+ * @example
1152
+ * const sig = await majik.signContent(documentBytes, { contentType: "application/pdf" });
1153
+ * const b64 = sig.serialize(); // store alongside the document
1154
+ */
1155
+ async signContent(content, options) {
1156
+ const id = options?.accountId ?? this.getActiveAccount()?.id;
1157
+ if (!id)
1158
+ throw new Error("No active account — call setActiveAccount() first");
1159
+ try {
1160
+ await MajikKeyStore.ensureUnlocked(id);
1161
+ const key = MajikKeyStore.get(id);
1162
+ if (!key)
1163
+ throw new Error(`Account not found in keystore: "${id}"`);
1164
+ if (!key.hasSigningKeys) {
1165
+ throw new Error(`Account "${id}" has no signing keys. ` +
1166
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
1167
+ }
1168
+ return MajikSignature.sign(content, key, {
1169
+ contentType: options?.contentType,
1170
+ timestamp: options?.timestamp,
1171
+ });
1172
+ }
1173
+ catch (err) {
1174
+ this.emit("error", err, { context: "signContent" });
1175
+ throw err;
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Sign a file and embed the signature directly into it using the active account.
1180
+ *
1181
+ * Format is auto-detected from magic bytes — PDF stays PDF, WAV stays WAV, etc.
1182
+ * Strips any existing signature before signing (idempotent re-signing).
1183
+ * The active account is unlocked automatically if needed.
1184
+ *
1185
+ * @example
1186
+ * const { blob: signedPdf } = await majik.signFile(pdfBlob);
1187
+ * // signedPdf is a valid PDF with the signature embedded in its metadata
1188
+ *
1189
+ * @example — non-active account
1190
+ * const { blob } = await majik.signFile(wavBlob, { accountId: "acc_xyz" });
1191
+ */
1192
+ async signFile(file, options) {
1193
+ const id = options?.accountId ?? this.getActiveAccount()?.id;
1194
+ if (!id)
1195
+ throw new Error("No active account — call setActiveAccount() first");
1196
+ try {
1197
+ await MajikKeyStore.ensureUnlocked(id);
1198
+ const key = MajikKeyStore.get(id);
1199
+ if (!key)
1200
+ throw new Error(`Account not found in keystore: "${id}"`);
1201
+ if (!key.hasSigningKeys) {
1202
+ throw new Error(`Account "${id}" has no signing keys. ` +
1203
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
1204
+ }
1205
+ return MajikSignature.signFile(file, key, {
1206
+ contentType: options?.contentType,
1207
+ timestamp: options?.timestamp,
1208
+ mimeType: options?.mimeType,
1209
+ });
1210
+ }
1211
+ catch (err) {
1212
+ this.emit("error", err, { context: "signFile" });
1213
+ throw err;
1214
+ }
1215
+ }
1216
+ // ── Verification ──────────────────────────────────────────────────────────
1217
+ /**
1218
+ * Verify raw bytes or a string against a MajikSignature.
1219
+ *
1220
+ * The signer can be identified by:
1221
+ * - A contact ID from the contact directory
1222
+ * - A raw base64 public key string (same format used in contacts)
1223
+ * - A MajikKey instance directly
1224
+ *
1225
+ * If no signer is provided, the public keys embedded in the signature
1226
+ * envelope are used (self-reported — see security note below).
1227
+ *
1228
+ * > ⚠️ When no signer is provided, the extracted public keys are self-reported
1229
+ * > by whoever created the signature. Always cross-check `result.signerId`
1230
+ * > against a known contact fingerprint before trusting the result.
1231
+ *
1232
+ * @example — verify against a known contact
1233
+ * const result = await majik.verifyContent(docBytes, sig, { contactId: "contact_abc" });
1234
+ * if (result.valid) console.log("Authentic, signed by:", result.signerId);
1235
+ *
1236
+ * @example — verify using embedded keys (self-reported)
1237
+ * const result = await majik.verifyContent(docBytes, sig);
1238
+ * // always check result.signerId matches a known fingerprint
1239
+ */
1240
+ async verifyContent(content, signature, options) {
1241
+ try {
1242
+ const publicKeys = await this._resolveSignerPublicKeys(options);
1243
+ if (publicKeys) {
1244
+ return MajikSignature.verify(content, signature, publicKeys);
1245
+ }
1246
+ // No signer provided — extract keys from envelope (self-reported)
1247
+ const sig = signature instanceof MajikSignature
1248
+ ? signature
1249
+ : MajikSignature.fromJSON(signature);
1250
+ return MajikSignature.verify(content, sig, sig.extractPublicKeys());
1251
+ }
1252
+ catch (err) {
1253
+ this.emit("error", err, { context: "verifyContent" });
1254
+ throw err;
1255
+ }
1256
+ }
1257
+ /**
1258
+ * Verify a file's embedded signature.
1259
+ *
1260
+ * The signer can be identified by:
1261
+ * - A contact ID from the contact directory
1262
+ * - A raw base64 public key string
1263
+ * - A MajikKey instance directly
1264
+ *
1265
+ * If no signer is provided, the public keys embedded in the signature
1266
+ * envelope are used (self-reported — see security note on verifyContent).
1267
+ *
1268
+ * @example — verify a signed PDF against a known contact
1269
+ * const result = await majik.verifyFile(signedPdf, { contactId: "contact_abc" });
1270
+ * if (result.valid) console.log("Verified:", result.signerId, result.timestamp);
1271
+ *
1272
+ * @example — check own signed file using active account
1273
+ * const result = await majik.verifyFile(signedWav, {
1274
+ * contactId: majik.getActiveAccount()?.id,
1275
+ * });
1276
+ */
1277
+ async verifyFile(file, options) {
1278
+ try {
1279
+ const publicKeys = await this._resolveSignerPublicKeys(options);
1280
+ if (publicKeys) {
1281
+ return MajikSignature.verifyFile(file, publicKeys, {
1282
+ expectedSignerId: options?.expectedSignerId,
1283
+ mimeType: options?.mimeType,
1284
+ });
1285
+ }
1286
+ // No signer provided — extract and use self-reported keys
1287
+ const extracted = await MajikSignature.extractFrom(file, {
1288
+ mimeType: options?.mimeType,
1289
+ });
1290
+ if (!extracted) {
1291
+ return {
1292
+ valid: false,
1293
+ signerId: "",
1294
+ contentHash: "",
1295
+ timestamp: new Date().toISOString(),
1296
+ reason: "No embedded signature found",
1297
+ };
1298
+ }
1299
+ return MajikSignature.verifyFile(file, extracted.extractPublicKeys(), {
1300
+ expectedSignerId: options?.expectedSignerId,
1301
+ mimeType: options?.mimeType,
1302
+ });
1303
+ }
1304
+ catch (err) {
1305
+ this.emit("error", err, { context: "verifyFile" });
1306
+ throw err;
1307
+ }
1308
+ }
1309
+ // ── Signature Utilities ───────────────────────────────────────────────────
1310
+ /**
1311
+ * Extract the embedded MajikSignature from a file.
1312
+ * Returns a fully typed MajikSignature instance, or null if not found.
1313
+ *
1314
+ * Does not verify — use verifyFile() to verify.
1315
+ *
1316
+ * @example
1317
+ * const sig = await majik.extractSignature(file);
1318
+ * if (sig) console.log("Signed by:", sig.signerId, "at", sig.timestamp);
1319
+ */
1320
+ async extractSignature(file, options) {
1321
+ try {
1322
+ return MajikSignature.extractFrom(file, options);
1323
+ }
1324
+ catch (err) {
1325
+ this.emit("error", err, { context: "extractSignature" });
1326
+ throw err;
1327
+ }
1328
+ }
1329
+ /**
1330
+ * Return a clean copy of the file with any embedded signature removed.
1331
+ * The returned bytes are exactly what was originally signed.
1332
+ *
1333
+ * Useful before re-processing or re-encrypting a signed file.
1334
+ *
1335
+ * @example
1336
+ * const originalBlob = await majik.stripSignature(signedMp4);
1337
+ */
1338
+ async stripSignature(file, options) {
1339
+ try {
1340
+ return MajikSignature.stripFrom(file, options);
1341
+ }
1342
+ catch (err) {
1343
+ this.emit("error", err, { context: "stripSignature" });
1344
+ throw err;
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Check whether a file contains an embedded MajikSignature.
1349
+ * Does not verify — purely a structural presence check.
1350
+ *
1351
+ * @example
1352
+ * if (await majik.isFileSigned(file)) {
1353
+ * const result = await majik.verifyFile(file, { contactId });
1354
+ * }
1355
+ */
1356
+ async isFileSigned(file, options) {
1357
+ try {
1358
+ return MajikSignature.isSigned(file, options);
1359
+ }
1360
+ catch (err) {
1361
+ this.emit("error", err, { context: "isFileSigned" });
1362
+ throw err;
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Get the public keys for the active account, ready for use with
1367
+ * MajikSignature.verify() or for sharing with another party.
1368
+ *
1369
+ * Works on locked keys — only reads public fields.
1370
+ *
1371
+ * @example
1372
+ * const myKeys = await majik.getSigningPublicKeys();
1373
+ * // share myKeys with someone so they can verify your signatures
1374
+ */
1375
+ async getSigningPublicKeys(accountId) {
1376
+ const id = accountId ?? this.getActiveAccount()?.id;
1377
+ if (!id)
1378
+ throw new Error("No active account — call setActiveAccount() first");
1379
+ const key = MajikKeyStore.get(id);
1380
+ if (!key)
1381
+ throw new Error(`Account not found in keystore: "${id}"`);
1382
+ if (!key.hasSigningKeys) {
1383
+ throw new Error(`Account "${id}" has no signing keys. ` +
1384
+ `Re-import via importAccountFromMnemonicBackup() to enable signing.`);
1385
+ }
1386
+ return MajikSignature.publicKeysFromMajikKey(key);
1387
+ }
1388
+ // ── Private: Signer resolution ────────────────────────────────────────────
1389
+ /**
1390
+ * Resolve MajikSignerPublicKeys from whichever signer hint was provided.
1391
+ * Returns null if no hint was given (caller should fall back to self-reported keys).
1392
+ *
1393
+ * Mirrors the _resolveRecipients / _resolveFileIdentity pattern used
1394
+ * throughout MajikMessage — consistent account/contact resolution in one place.
1395
+ */
1396
+ async _resolveSignerPublicKeys(options) {
1397
+ if (!options)
1398
+ return null;
1399
+ // Option A: caller passed a MajikKey instance directly
1400
+ if (options.key) {
1401
+ return MajikSignature.publicKeysFromMajikKey(options.key);
1402
+ }
1403
+ // Option B: contact ID looked up from the contact directory
1404
+ if (options.contactId) {
1405
+ const contact = this.contactDirectory.getContact(options.contactId);
1406
+ if (!contact) {
1407
+ throw new Error(`No contact found for id "${options.contactId}"`);
1408
+ }
1409
+ // Own accounts are in the keystore — get their signing keys directly
1410
+ const ownAccount = this.getOwnAccountById(options.contactId);
1411
+ if (ownAccount) {
1412
+ const key = MajikKeyStore.get(options.contactId);
1413
+ if (key?.hasSigningKeys) {
1414
+ return MajikSignature.publicKeysFromMajikKey(key);
1415
+ }
1416
+ }
1417
+ // External contact — resolve from their contact card fields
1418
+ if (!contact.edPublicKeyBase64 || !contact.mlDsaPublicKeyBase64) {
1419
+ throw new Error(`Contact "${options.contactId}" has no signing public keys. ` +
1420
+ `They may need to share an updated contact card.`);
1421
+ }
1422
+ return {
1423
+ signerId: contact.fingerprint,
1424
+ edPublicKey: base64ToUint8Array(contact.edPublicKeyBase64),
1425
+ mlDsaPublicKey: base64ToUint8Array(contact.mlDsaPublicKeyBase64),
1426
+ };
1427
+ }
1428
+ // Option C: raw base64 public key — look up via contact directory
1429
+ if (options.publicKeyBase64) {
1430
+ const contact = await this.contactDirectory.getContactByPublicKeyBase64(options.publicKeyBase64);
1431
+ if (!contact) {
1432
+ throw new Error(`No contact found for public key "${options.publicKeyBase64}"`);
1433
+ }
1434
+ if (!contact.edPublicKeyBase64 || !contact.mlDsaPublicKeyBase64) {
1435
+ throw new Error(`Contact for key "${options.publicKeyBase64}" has no signing public keys.`);
1436
+ }
1437
+ return {
1438
+ signerId: contact.fingerprint,
1439
+ edPublicKey: base64ToUint8Array(contact.edPublicKeyBase64),
1440
+ mlDsaPublicKey: base64ToUint8Array(contact.mlDsaPublicKeyBase64),
1441
+ };
1442
+ }
1443
+ return null;
1444
+ }
893
1445
  // ── PIN ───────────────────────────────────────────────────────────────────
894
1446
  async setPIN(pin) {
895
1447
  if (!pin)