@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.
- package/dist/core/contacts/majik-contact.d.ts +8 -0
- package/dist/core/contacts/majik-contact.js +8 -0
- package/dist/core/crypto/keystore.d.ts +0 -18
- package/dist/core/crypto/keystore.js +0 -18
- package/dist/core/types.d.ts +11 -0
- package/dist/majik-message.d.ts +368 -44
- package/dist/majik-message.js +621 -69
- package/package.json +5 -4
package/dist/majik-message.js
CHANGED
|
@@ -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
|
-
*
|
|
633
|
-
*
|
|
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
|
-
* @
|
|
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.
|
|
675
|
-
|
|
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
|
|
683
|
+
threadId,
|
|
690
684
|
compressionLevel,
|
|
691
|
-
}
|
|
692
|
-
// ──
|
|
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
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
724
|
-
* const
|
|
725
|
-
*
|
|
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
|
-
|
|
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)
|