@thezelijah/majik-message 1.0.0
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/LICENSE +67 -0
- package/README.md +265 -0
- package/dist/core/contacts/majik-contact-directory.d.ts +34 -0
- package/dist/core/contacts/majik-contact-directory.js +165 -0
- package/dist/core/contacts/majik-contact.d.ts +53 -0
- package/dist/core/contacts/majik-contact.js +135 -0
- package/dist/core/crypto/constants.d.ts +7 -0
- package/dist/core/crypto/constants.js +6 -0
- package/dist/core/crypto/crypto-provider.d.ts +20 -0
- package/dist/core/crypto/crypto-provider.js +70 -0
- package/dist/core/crypto/encryption-engine.d.ts +59 -0
- package/dist/core/crypto/encryption-engine.js +257 -0
- package/dist/core/crypto/keystore.d.ts +126 -0
- package/dist/core/crypto/keystore.js +575 -0
- package/dist/core/messages/envelope-cache.d.ts +51 -0
- package/dist/core/messages/envelope-cache.js +375 -0
- package/dist/core/messages/message-envelope.d.ts +36 -0
- package/dist/core/messages/message-envelope.js +161 -0
- package/dist/core/scanner/scanner-engine.d.ts +27 -0
- package/dist/core/scanner/scanner-engine.js +120 -0
- package/dist/core/types.d.ts +23 -0
- package/dist/core/types.js +1 -0
- package/dist/core/utils/APITranscoder.d.ts +114 -0
- package/dist/core/utils/APITranscoder.js +305 -0
- package/dist/core/utils/idb-majik-system.d.ts +15 -0
- package/dist/core/utils/idb-majik-system.js +37 -0
- package/dist/core/utils/majik-file-utils.d.ts +16 -0
- package/dist/core/utils/majik-file-utils.js +153 -0
- package/dist/core/utils/utilities.d.ts +22 -0
- package/dist/core/utils/utilities.js +80 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/majik-message.d.ts +202 -0
- package/dist/majik-message.js +940 -0
- package/package.json +97 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { arrayBufferToBase64 } from "../utils/utilities";
|
|
2
|
+
/* -------------------------------
|
|
3
|
+
* Errors
|
|
4
|
+
* ------------------------------- */
|
|
5
|
+
export class MajikContactError extends Error {
|
|
6
|
+
cause;
|
|
7
|
+
constructor(message, cause) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "MajikContactError";
|
|
10
|
+
this.cause = cause;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/* -------------------------------
|
|
14
|
+
* MajikContact Class
|
|
15
|
+
* ------------------------------- */
|
|
16
|
+
export class MajikContact {
|
|
17
|
+
id;
|
|
18
|
+
publicKey;
|
|
19
|
+
fingerprint;
|
|
20
|
+
meta;
|
|
21
|
+
constructor(data) {
|
|
22
|
+
this.assertId(data.id);
|
|
23
|
+
this.assertPublicKey(data.publicKey);
|
|
24
|
+
this.assertFingerprint(data.fingerprint);
|
|
25
|
+
this.id = data.id;
|
|
26
|
+
this.publicKey = data.publicKey;
|
|
27
|
+
this.fingerprint = data.fingerprint;
|
|
28
|
+
this.meta = {
|
|
29
|
+
label: data.meta?.label || "",
|
|
30
|
+
notes: data.meta?.notes || "",
|
|
31
|
+
blocked: data.meta?.blocked || false,
|
|
32
|
+
createdAt: data.meta?.createdAt || new Date().toISOString(),
|
|
33
|
+
updatedAt: data.meta?.updatedAt || new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
static create(id, publicKey, fingerprint, meta) {
|
|
37
|
+
return new MajikContact({
|
|
38
|
+
id,
|
|
39
|
+
publicKey,
|
|
40
|
+
fingerprint,
|
|
41
|
+
meta,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
assertId(id) {
|
|
45
|
+
if (!id || typeof id !== "string") {
|
|
46
|
+
throw new MajikContactError("Contact ID must be a non-empty string");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
assertPublicKey(key) {
|
|
50
|
+
// Accept either a WebCrypto CryptoKey (with .type === 'public')
|
|
51
|
+
// or a raw-key wrapper object that contains a Uint8Array `raw` field.
|
|
52
|
+
if (!key)
|
|
53
|
+
throw new MajikContactError("Invalid public key");
|
|
54
|
+
const anyKey = key;
|
|
55
|
+
if (anyKey && typeof anyKey === "object") {
|
|
56
|
+
if (anyKey.type === "public")
|
|
57
|
+
return;
|
|
58
|
+
if (anyKey.raw instanceof Uint8Array)
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
throw new MajikContactError("Invalid public key");
|
|
62
|
+
}
|
|
63
|
+
assertFingerprint(fingerprint) {
|
|
64
|
+
if (!fingerprint || typeof fingerprint !== "string") {
|
|
65
|
+
throw new MajikContactError("Fingerprint must be a non-empty string");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
updateTimestamp() {
|
|
69
|
+
this.meta.updatedAt = new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
updateLabel(label) {
|
|
72
|
+
if (typeof label !== "string")
|
|
73
|
+
throw new MajikContactError("Label must be a string");
|
|
74
|
+
this.meta.label = label;
|
|
75
|
+
this.updateTimestamp();
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
updateNotes(notes) {
|
|
79
|
+
if (typeof notes !== "string")
|
|
80
|
+
throw new MajikContactError("Notes must be a string");
|
|
81
|
+
this.meta.notes = notes;
|
|
82
|
+
this.updateTimestamp();
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
isBlocked() {
|
|
86
|
+
return this.meta.blocked || false;
|
|
87
|
+
}
|
|
88
|
+
setBlocked(blocked) {
|
|
89
|
+
if (typeof blocked !== "boolean")
|
|
90
|
+
throw new MajikContactError("Blocked must be boolean");
|
|
91
|
+
this.meta.blocked = blocked;
|
|
92
|
+
this.updateTimestamp();
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
// Idempotent block/unblock for safe scanning
|
|
96
|
+
block() {
|
|
97
|
+
if (!this.isBlocked())
|
|
98
|
+
this.setBlocked(true);
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
unblock() {
|
|
102
|
+
if (this.isBlocked())
|
|
103
|
+
this.setBlocked(false);
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
async toJSON() {
|
|
107
|
+
// Support both CryptoKey and raw-key wrappers (fallbacks when WebCrypto X25519 unsupported)
|
|
108
|
+
try {
|
|
109
|
+
// If it's a CryptoKey, export with SubtleCrypto
|
|
110
|
+
const raw = await crypto.subtle.exportKey("raw", this.publicKey);
|
|
111
|
+
return {
|
|
112
|
+
id: this.id,
|
|
113
|
+
fingerprint: this.fingerprint,
|
|
114
|
+
meta: { ...this.meta },
|
|
115
|
+
publicKeyBase64: arrayBufferToBase64(raw),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
// Fallback: publicKey may be a wrapper with `raw` Uint8Array
|
|
120
|
+
const maybe = this.publicKey;
|
|
121
|
+
if (maybe && maybe.raw instanceof Uint8Array) {
|
|
122
|
+
return {
|
|
123
|
+
id: this.id,
|
|
124
|
+
fingerprint: this.fingerprint,
|
|
125
|
+
meta: { ...this.meta },
|
|
126
|
+
publicKeyBase64: arrayBufferToBase64(maybe.raw.buffer),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
static isBlocked(contact) {
|
|
133
|
+
return !!contact.meta.blocked;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const IV_LENGTH = 12;
|
|
2
|
+
export declare function generateRandomBytes(len: number): Uint8Array;
|
|
3
|
+
export declare function generateEd25519Keypair(): {
|
|
4
|
+
edPublic: Uint8Array<ArrayBufferLike>;
|
|
5
|
+
edSecret: Uint8Array<ArrayBufferLike>;
|
|
6
|
+
xPublic: Uint8Array<ArrayBuffer> | null;
|
|
7
|
+
xSecret: Uint8Array<ArrayBuffer> | null;
|
|
8
|
+
};
|
|
9
|
+
export declare function deriveEd25519FromSeed(seed32: Uint8Array): {
|
|
10
|
+
edPublic: Uint8Array<ArrayBufferLike>;
|
|
11
|
+
edSecret: Uint8Array<ArrayBufferLike>;
|
|
12
|
+
xPublic: Uint8Array<ArrayBuffer> | null;
|
|
13
|
+
xSecret: Uint8Array<ArrayBuffer> | null;
|
|
14
|
+
};
|
|
15
|
+
export declare function fingerprintFromPublicRaw(rawPublic: Uint8Array): string;
|
|
16
|
+
export declare function aesGcmEncrypt(keyBytes: Uint8Array, iv: Uint8Array, plaintext: Uint8Array): Uint8Array;
|
|
17
|
+
export declare function aesGcmDecrypt(keyBytes: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array): Uint8Array | null;
|
|
18
|
+
export declare function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations?: number, keyLen?: number): Uint8Array;
|
|
19
|
+
export declare function deriveKeyFromMnemonic(mnemonic: string, salt: Uint8Array, iterations?: number, keyLen?: number): Uint8Array;
|
|
20
|
+
export declare function x25519SharedSecret(privRaw: Uint8Array, pubRaw: Uint8Array): Uint8Array;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as ed25519 from "@stablelib/ed25519";
|
|
2
|
+
import ed2curve from "ed2curve";
|
|
3
|
+
import { AES } from "@stablelib/aes";
|
|
4
|
+
import { GCM } from "@stablelib/gcm";
|
|
5
|
+
import { deriveKey } from "@stablelib/pbkdf2";
|
|
6
|
+
import { hash, SHA256 } from "@stablelib/sha256";
|
|
7
|
+
import * as x25519 from "@stablelib/x25519";
|
|
8
|
+
import { arrayToBase64 } from "../utils/utilities";
|
|
9
|
+
export const IV_LENGTH = 12;
|
|
10
|
+
export function generateRandomBytes(len) {
|
|
11
|
+
const b = new Uint8Array(len);
|
|
12
|
+
crypto.getRandomValues(b);
|
|
13
|
+
return b;
|
|
14
|
+
}
|
|
15
|
+
export function generateEd25519Keypair() {
|
|
16
|
+
const ed = ed25519.generateKeyPair();
|
|
17
|
+
const pkCurve = ed2curve.convertPublicKey(ed.publicKey);
|
|
18
|
+
const skCurve = ed2curve.convertSecretKey(ed.secretKey);
|
|
19
|
+
return {
|
|
20
|
+
edPublic: ed.publicKey,
|
|
21
|
+
edSecret: ed.secretKey,
|
|
22
|
+
xPublic: pkCurve ? new Uint8Array(pkCurve) : null,
|
|
23
|
+
xSecret: skCurve ? new Uint8Array(skCurve) : null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function deriveEd25519FromSeed(seed32) {
|
|
27
|
+
const ed = ed25519.generateKeyPairFromSeed(seed32);
|
|
28
|
+
const pkCurve = ed2curve.convertPublicKey(ed.publicKey);
|
|
29
|
+
const skCurve = ed2curve.convertSecretKey(ed.secretKey);
|
|
30
|
+
return {
|
|
31
|
+
edPublic: ed.publicKey,
|
|
32
|
+
edSecret: ed.secretKey,
|
|
33
|
+
xPublic: pkCurve ? new Uint8Array(pkCurve) : null,
|
|
34
|
+
xSecret: skCurve ? new Uint8Array(skCurve) : null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function fingerprintFromPublicRaw(rawPublic) {
|
|
38
|
+
const digest = hash(rawPublic);
|
|
39
|
+
return arrayToBase64(digest);
|
|
40
|
+
}
|
|
41
|
+
export function aesGcmEncrypt(keyBytes, iv, plaintext) {
|
|
42
|
+
const aes = new AES(keyBytes);
|
|
43
|
+
const gcm = new GCM(aes);
|
|
44
|
+
return gcm.seal(iv, plaintext);
|
|
45
|
+
}
|
|
46
|
+
export function aesGcmDecrypt(keyBytes, iv, ciphertext) {
|
|
47
|
+
const aes = new AES(keyBytes);
|
|
48
|
+
const gcm = new GCM(aes);
|
|
49
|
+
return gcm.open(iv, ciphertext);
|
|
50
|
+
}
|
|
51
|
+
export function deriveKeyFromPassphrase(passphrase, salt, iterations = 250000, keyLen = 32) {
|
|
52
|
+
const pw = new TextEncoder().encode(passphrase);
|
|
53
|
+
return deriveKey(SHA256, pw, salt, iterations, keyLen);
|
|
54
|
+
}
|
|
55
|
+
export function deriveKeyFromMnemonic(mnemonic, salt, iterations = 200000, keyLen = 32) {
|
|
56
|
+
const m = new TextEncoder().encode(mnemonic);
|
|
57
|
+
return deriveKey(SHA256, m, salt, iterations, keyLen);
|
|
58
|
+
}
|
|
59
|
+
export function x25519SharedSecret(privRaw, pubRaw) {
|
|
60
|
+
// Use @stablelib/x25519 for scalar multiplication / shared secret
|
|
61
|
+
const priv = new Uint8Array(privRaw);
|
|
62
|
+
const pub = new Uint8Array(pubRaw);
|
|
63
|
+
if (x25519.scalarMult) {
|
|
64
|
+
return x25519.scalarMult(priv, pub);
|
|
65
|
+
}
|
|
66
|
+
if (x25519.sharedKey) {
|
|
67
|
+
return x25519.sharedKey(priv, pub);
|
|
68
|
+
}
|
|
69
|
+
throw new Error("@stablelib/x25519: compatible API not found");
|
|
70
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { MultiRecipientPayload, SingleRecipientPayload } from "../types";
|
|
2
|
+
export interface EncryptionIdentity {
|
|
3
|
+
publicKey: CryptoKey | {
|
|
4
|
+
raw: Uint8Array;
|
|
5
|
+
};
|
|
6
|
+
privateKey: CryptoKey | {
|
|
7
|
+
raw: Uint8Array;
|
|
8
|
+
};
|
|
9
|
+
fingerprint: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* EncryptionEngine
|
|
13
|
+
* ----------------
|
|
14
|
+
* Core cryptographic engine for MajikMessage.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ Background-only. Never import this into content scripts.
|
|
17
|
+
*/
|
|
18
|
+
export declare class EncryptionEngine {
|
|
19
|
+
/**
|
|
20
|
+
* Generates a long-term X25519 identity keypair.
|
|
21
|
+
*/
|
|
22
|
+
static generateIdentity(): Promise<EncryptionIdentity>;
|
|
23
|
+
/**
|
|
24
|
+
* Derive an identity deterministically from a BIP39 mnemonic.
|
|
25
|
+
* Uses Stablelib Ed25519 to derive a keypair from seed and converts to X25519.
|
|
26
|
+
*/
|
|
27
|
+
static deriveIdentityFromMnemonic(mnemonic: string): Promise<EncryptionIdentity>;
|
|
28
|
+
static encryptSoloMessage(plaintext: string, recipientPublicKey: CryptoKey | {
|
|
29
|
+
raw: Uint8Array;
|
|
30
|
+
}): Promise<SingleRecipientPayload>;
|
|
31
|
+
static decryptSoloMessage(payload: SingleRecipientPayload, recipientPrivateKey: CryptoKey | {
|
|
32
|
+
raw: Uint8Array;
|
|
33
|
+
}): Promise<string>;
|
|
34
|
+
static encryptGroupMessage(plaintext: string, recipients: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
publicKey: CryptoKey | {
|
|
37
|
+
raw: Uint8Array;
|
|
38
|
+
};
|
|
39
|
+
}>): Promise<MultiRecipientPayload>;
|
|
40
|
+
static decryptGroupMessage(payload: MultiRecipientPayload, recipient: CryptoKey | {
|
|
41
|
+
raw: Uint8Array;
|
|
42
|
+
}, fingerprint: string): Promise<string>;
|
|
43
|
+
/**
|
|
44
|
+
* Generates a SHA-256 fingerprint from a public key.
|
|
45
|
+
*/
|
|
46
|
+
static fingerprintFromPublicKey(publicKey: CryptoKey | {
|
|
47
|
+
raw: Uint8Array;
|
|
48
|
+
}): Promise<string>;
|
|
49
|
+
private static _extractPublicRaw;
|
|
50
|
+
private static _extractPrivateRaw;
|
|
51
|
+
private static assertNonEmptyString;
|
|
52
|
+
private static assertPublicKey;
|
|
53
|
+
private static assertPrivateKey;
|
|
54
|
+
private static assertPayload;
|
|
55
|
+
}
|
|
56
|
+
export declare class CryptoError extends Error {
|
|
57
|
+
cause?: unknown;
|
|
58
|
+
constructor(message: string, cause?: unknown);
|
|
59
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { arrayToBase64, base64ToArrayBuffer } from "../utils/utilities";
|
|
2
|
+
import { mnemonicToSeedSync } from "@scure/bip39";
|
|
3
|
+
import * as ed25519 from "@stablelib/ed25519";
|
|
4
|
+
import ed2curve from "ed2curve";
|
|
5
|
+
import nacl from "tweetnacl";
|
|
6
|
+
import { hash } from "@stablelib/sha256";
|
|
7
|
+
import { x25519SharedSecret, generateRandomBytes, aesGcmEncrypt, aesGcmDecrypt, fingerprintFromPublicRaw, IV_LENGTH, } from "./crypto-provider";
|
|
8
|
+
/**
|
|
9
|
+
* EncryptionEngine
|
|
10
|
+
* ----------------
|
|
11
|
+
* Core cryptographic engine for MajikMessage.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ Background-only. Never import this into content scripts.
|
|
14
|
+
*/
|
|
15
|
+
export class EncryptionEngine {
|
|
16
|
+
/* ================================
|
|
17
|
+
* Identity
|
|
18
|
+
* ================================ */
|
|
19
|
+
/**
|
|
20
|
+
* Generates a long-term X25519 identity keypair.
|
|
21
|
+
*/
|
|
22
|
+
static async generateIdentity() {
|
|
23
|
+
try {
|
|
24
|
+
// Generate an Ed25519 keypair (stablelib) and convert to Curve25519
|
|
25
|
+
const ed = ed25519.generateKeyPair();
|
|
26
|
+
const skCurve = ed2curve.convertSecretKey(ed.secretKey);
|
|
27
|
+
const pkCurve = ed2curve.convertPublicKey(ed.publicKey);
|
|
28
|
+
if (!skCurve || !pkCurve) {
|
|
29
|
+
throw new CryptoError("Failed to convert Ed25519 keys to Curve25519");
|
|
30
|
+
}
|
|
31
|
+
const pkBytes = new Uint8Array(pkCurve);
|
|
32
|
+
const skBytes = new Uint8Array(skCurve);
|
|
33
|
+
// Use raw key wrappers (Stablelib-backed) to avoid WebCrypto import variability
|
|
34
|
+
const publicKey = { type: "public", raw: pkBytes };
|
|
35
|
+
const privateKey = { type: "private", raw: skBytes };
|
|
36
|
+
const fingerprint = fingerprintFromPublicRaw(pkBytes);
|
|
37
|
+
return { publicKey, privateKey, fingerprint };
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
throw new CryptoError("Failed to generate identity", err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Derive an identity deterministically from a BIP39 mnemonic.
|
|
45
|
+
* Uses Stablelib Ed25519 to derive a keypair from seed and converts to X25519.
|
|
46
|
+
*/
|
|
47
|
+
static async deriveIdentityFromMnemonic(mnemonic) {
|
|
48
|
+
try {
|
|
49
|
+
if (typeof mnemonic !== "string" || mnemonic.trim().length === 0) {
|
|
50
|
+
throw new CryptoError("Mnemonic must be a non-empty string");
|
|
51
|
+
}
|
|
52
|
+
// Convert mnemonic to seed (64 bytes) then reduce to 32 bytes
|
|
53
|
+
const seed = mnemonicToSeedSync(mnemonic); // Buffer
|
|
54
|
+
const seed32 = new Uint8Array(seed.slice(0, 32));
|
|
55
|
+
// Derive Ed25519 keypair from seed (stablelib)
|
|
56
|
+
const ed = ed25519.generateKeyPairFromSeed(seed32);
|
|
57
|
+
// Convert Ed25519 keys to X25519 (curve25519)
|
|
58
|
+
const skCurve = ed2curve.convertSecretKey(ed.secretKey);
|
|
59
|
+
const pkCurve = ed2curve.convertPublicKey(ed.publicKey);
|
|
60
|
+
if (!skCurve || !pkCurve) {
|
|
61
|
+
throw new CryptoError("Failed to convert derived Ed25519 keys to Curve25519");
|
|
62
|
+
}
|
|
63
|
+
// Ensure plain Uint8Array
|
|
64
|
+
const pkCurveBytes = new Uint8Array(pkCurve);
|
|
65
|
+
const skCurveBytes = new Uint8Array(skCurve);
|
|
66
|
+
const publicKey = { type: "public", raw: pkCurveBytes };
|
|
67
|
+
const privateKey = { type: "private", raw: skCurveBytes };
|
|
68
|
+
const fingerprint = fingerprintFromPublicRaw(pkCurveBytes);
|
|
69
|
+
return { publicKey, privateKey, fingerprint };
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
throw new CryptoError("Failed to derive identity from mnemonic", err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/* ================================
|
|
76
|
+
* Solo Encryption (existing original logic)
|
|
77
|
+
* ================================ */
|
|
78
|
+
static async encryptSoloMessage(plaintext, recipientPublicKey) {
|
|
79
|
+
this.assertNonEmptyString(plaintext);
|
|
80
|
+
this.assertPublicKey(recipientPublicKey);
|
|
81
|
+
const recipientRaw = await this._extractPublicRaw(recipientPublicKey);
|
|
82
|
+
// Ephemeral X25519 keypair
|
|
83
|
+
const ephPrivate = generateRandomBytes(32);
|
|
84
|
+
const ephPublic = nacl.scalarMult.base(ephPrivate);
|
|
85
|
+
// Shared secret -> AES key
|
|
86
|
+
const shared = x25519SharedSecret(ephPrivate, recipientRaw);
|
|
87
|
+
const aesKey = hash(shared);
|
|
88
|
+
const iv = generateRandomBytes(IV_LENGTH);
|
|
89
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
90
|
+
const ciphertext = aesGcmEncrypt(aesKey, iv, encoded);
|
|
91
|
+
return {
|
|
92
|
+
iv: arrayToBase64(iv),
|
|
93
|
+
ciphertext: arrayToBase64(ciphertext),
|
|
94
|
+
ephemeralPublicKey: arrayToBase64(ephPublic),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
static async decryptSoloMessage(payload, recipientPrivateKey) {
|
|
98
|
+
this.assertPrivateKey(recipientPrivateKey);
|
|
99
|
+
this.assertPayload(payload);
|
|
100
|
+
const privRaw = this._extractPrivateRaw(recipientPrivateKey);
|
|
101
|
+
const ephRaw = new Uint8Array(base64ToArrayBuffer(payload.ephemeralPublicKey));
|
|
102
|
+
const shared = x25519SharedSecret(privRaw, ephRaw);
|
|
103
|
+
const aesKey = hash(shared);
|
|
104
|
+
const iv = new Uint8Array(base64ToArrayBuffer(payload.iv));
|
|
105
|
+
const ciphertext = new Uint8Array(base64ToArrayBuffer(payload.ciphertext));
|
|
106
|
+
const plain = aesGcmDecrypt(aesKey, iv, ciphertext);
|
|
107
|
+
if (!plain)
|
|
108
|
+
throw new CryptoError("Decryption failed (auth failed)");
|
|
109
|
+
return new TextDecoder().decode(plain);
|
|
110
|
+
}
|
|
111
|
+
/* ================================
|
|
112
|
+
* Group Encryption
|
|
113
|
+
* ================================ */
|
|
114
|
+
static async encryptGroupMessage(plaintext, recipients) {
|
|
115
|
+
this.assertNonEmptyString(plaintext);
|
|
116
|
+
if (!recipients || recipients.length === 0) {
|
|
117
|
+
throw new CryptoError("No recipients provided");
|
|
118
|
+
}
|
|
119
|
+
// Generate ephemeral AES key for the message
|
|
120
|
+
const aesKey = generateRandomBytes(32);
|
|
121
|
+
const iv = generateRandomBytes(IV_LENGTH);
|
|
122
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
123
|
+
const ciphertext = aesGcmEncrypt(aesKey, iv, encoded);
|
|
124
|
+
// Ephemeral X25519 keypair for encrypting AES key
|
|
125
|
+
const ephPrivate = generateRandomBytes(32);
|
|
126
|
+
const ephPublic = nacl.scalarMult.base(ephPrivate);
|
|
127
|
+
const keys = await Promise.all(recipients.map(async (r) => {
|
|
128
|
+
const recipientRaw = await this._extractPublicRaw(r.publicKey);
|
|
129
|
+
const shared = x25519SharedSecret(ephPrivate, recipientRaw);
|
|
130
|
+
const nonce = generateRandomBytes(24); // random per recipient
|
|
131
|
+
const ephemeralEncryptedKey = nacl.secretbox(aesKey, nonce, shared);
|
|
132
|
+
return {
|
|
133
|
+
fingerprint: fingerprintFromPublicRaw(recipientRaw),
|
|
134
|
+
ephemeralEncryptedKey: arrayToBase64(ephemeralEncryptedKey),
|
|
135
|
+
nonce: arrayToBase64(nonce),
|
|
136
|
+
};
|
|
137
|
+
}));
|
|
138
|
+
return {
|
|
139
|
+
iv: arrayToBase64(iv),
|
|
140
|
+
ciphertext: arrayToBase64(ciphertext),
|
|
141
|
+
keys,
|
|
142
|
+
ephemeralPublicKey: arrayToBase64(ephPublic), // needed for decryption
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
static async decryptGroupMessage(payload, recipient, fingerprint) {
|
|
146
|
+
this.assertPrivateKey(recipient);
|
|
147
|
+
this.assertPayload(payload);
|
|
148
|
+
const keyEntry = payload.keys.find((k) => k.fingerprint === fingerprint);
|
|
149
|
+
if (!keyEntry)
|
|
150
|
+
throw new CryptoError("No encrypted key for this recipient");
|
|
151
|
+
const privRaw = this._extractPrivateRaw(recipient);
|
|
152
|
+
const ephPublic = new Uint8Array(base64ToArrayBuffer(payload.ephemeralPublicKey));
|
|
153
|
+
const shared = x25519SharedSecret(privRaw, ephPublic);
|
|
154
|
+
const nonce = new Uint8Array(base64ToArrayBuffer(keyEntry.nonce));
|
|
155
|
+
const encryptedKey = new Uint8Array(base64ToArrayBuffer(keyEntry.ephemeralEncryptedKey));
|
|
156
|
+
const aesKey = nacl.secretbox.open(encryptedKey, nonce, shared);
|
|
157
|
+
if (!aesKey)
|
|
158
|
+
throw new CryptoError("Failed to decrypt AES key for group message");
|
|
159
|
+
const iv = new Uint8Array(base64ToArrayBuffer(payload.iv));
|
|
160
|
+
const ciphertext = new Uint8Array(base64ToArrayBuffer(payload.ciphertext));
|
|
161
|
+
const plain = aesGcmDecrypt(aesKey, iv, ciphertext);
|
|
162
|
+
if (!plain)
|
|
163
|
+
throw new CryptoError("Failed to decrypt group message");
|
|
164
|
+
return new TextDecoder().decode(plain);
|
|
165
|
+
}
|
|
166
|
+
/* ================================
|
|
167
|
+
* Fingerprinting
|
|
168
|
+
* ================================ */
|
|
169
|
+
/**
|
|
170
|
+
* Generates a SHA-256 fingerprint from a public key.
|
|
171
|
+
*/
|
|
172
|
+
static async fingerprintFromPublicKey(publicKey) {
|
|
173
|
+
// Accept both CryptoKey and raw wrappers; use stablelib sha256 via provider
|
|
174
|
+
const anyKey = publicKey;
|
|
175
|
+
let rawBytes;
|
|
176
|
+
if (anyKey && anyKey.raw instanceof Uint8Array) {
|
|
177
|
+
rawBytes = anyKey.raw;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.assertPublicKey(publicKey);
|
|
181
|
+
const exported = await crypto.subtle.exportKey("raw", publicKey);
|
|
182
|
+
rawBytes = new Uint8Array(exported);
|
|
183
|
+
}
|
|
184
|
+
return fingerprintFromPublicRaw(rawBytes);
|
|
185
|
+
}
|
|
186
|
+
/* ================================
|
|
187
|
+
* Helpers
|
|
188
|
+
* ================================ */
|
|
189
|
+
static async _extractPublicRaw(key) {
|
|
190
|
+
// Unified stablelib-backed path: export recipient raw public key then do X25519 scalar-mult
|
|
191
|
+
const recipientAny = key;
|
|
192
|
+
let recipientRaw;
|
|
193
|
+
if (recipientAny && recipientAny.raw instanceof Uint8Array) {
|
|
194
|
+
return (recipientRaw = recipientAny.raw);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
try {
|
|
198
|
+
const exported = await crypto.subtle.exportKey("raw", key);
|
|
199
|
+
recipientRaw = new Uint8Array(exported);
|
|
200
|
+
return recipientRaw;
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
throw new CryptoError("Failed to export recipient public key", e);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
static _extractPrivateRaw(key) {
|
|
208
|
+
const anyKey = key;
|
|
209
|
+
if (anyKey.raw instanceof Uint8Array)
|
|
210
|
+
return anyKey.raw;
|
|
211
|
+
throw new CryptoError("Cannot extract raw private key");
|
|
212
|
+
}
|
|
213
|
+
/* ================================
|
|
214
|
+
* Validation Helpers
|
|
215
|
+
* ================================ */
|
|
216
|
+
static assertNonEmptyString(value) {
|
|
217
|
+
if (!value || typeof value !== "string") {
|
|
218
|
+
throw new CryptoError("Plaintext must be a non-empty string");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
static assertPublicKey(key) {
|
|
222
|
+
const anyKey = key;
|
|
223
|
+
if (!key)
|
|
224
|
+
throw new CryptoError("Invalid public key");
|
|
225
|
+
if (anyKey.raw instanceof Uint8Array)
|
|
226
|
+
return; // raw wrapper
|
|
227
|
+
if (key.type !== "public") {
|
|
228
|
+
throw new CryptoError("Invalid public key");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
static assertPrivateKey(key) {
|
|
232
|
+
const anyKey = key;
|
|
233
|
+
if (!key)
|
|
234
|
+
throw new CryptoError("Invalid private key");
|
|
235
|
+
if (anyKey.raw instanceof Uint8Array)
|
|
236
|
+
return; // raw wrapper
|
|
237
|
+
if (key.type !== "private") {
|
|
238
|
+
throw new CryptoError("Invalid private key");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
static assertPayload(payload) {
|
|
242
|
+
if (!payload?.iv || !payload?.ciphertext) {
|
|
243
|
+
throw new CryptoError("Malformed encrypted payload");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/* ================================
|
|
248
|
+
* Errors
|
|
249
|
+
* ================================ */
|
|
250
|
+
export class CryptoError extends Error {
|
|
251
|
+
cause;
|
|
252
|
+
constructor(message, cause) {
|
|
253
|
+
super(message);
|
|
254
|
+
this.name = "CryptoError";
|
|
255
|
+
this.cause = cause;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export interface KeyStoreIdentity {
|
|
2
|
+
id: string;
|
|
3
|
+
publicKey: CryptoKey | {
|
|
4
|
+
raw: Uint8Array;
|
|
5
|
+
};
|
|
6
|
+
fingerprint: string;
|
|
7
|
+
privateKey?: CryptoKey | {
|
|
8
|
+
raw: Uint8Array;
|
|
9
|
+
};
|
|
10
|
+
encryptedPrivateKey?: ArrayBuffer;
|
|
11
|
+
unlocked?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface SerializedIdentity {
|
|
14
|
+
id: string;
|
|
15
|
+
publicKey: string;
|
|
16
|
+
fingerprint: string;
|
|
17
|
+
encryptedPrivateKey?: string;
|
|
18
|
+
salt?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class KeyStoreError extends Error {
|
|
21
|
+
cause?: unknown;
|
|
22
|
+
constructor(message: string, cause?: unknown);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* KeyStore
|
|
26
|
+
* ----------------
|
|
27
|
+
* Secure storage for MajikMessage identities.
|
|
28
|
+
* Stores public keys and fingerprints in plaintext,
|
|
29
|
+
* encrypts private keys with a passphrase.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ Background-only. Never expose private keys to content scripts.
|
|
32
|
+
*/
|
|
33
|
+
export declare class KeyStore {
|
|
34
|
+
private static DB_NAME;
|
|
35
|
+
private static STORE_NAME;
|
|
36
|
+
private static DB_VERSION;
|
|
37
|
+
private static dbPromise;
|
|
38
|
+
private static unlockedIdentities;
|
|
39
|
+
static onUnlockRequested?: (id: string) => string | Promise<string>;
|
|
40
|
+
private static computeChecksum;
|
|
41
|
+
private static getDB;
|
|
42
|
+
private static putSerializedIdentity;
|
|
43
|
+
private static getSerializedIdentity;
|
|
44
|
+
/**
|
|
45
|
+
* Validates whether a passphrase can decrypt the stored private key.
|
|
46
|
+
* Does NOT unlock or mutate any in-memory state.
|
|
47
|
+
*/
|
|
48
|
+
static isPassphraseValid(id: string, passphrase: string): Promise<boolean>;
|
|
49
|
+
static updatePassphrase(id: string, currentPassphrase: string, newPassphrase: string): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Check if an identity exists in memory or in storage
|
|
52
|
+
*/
|
|
53
|
+
static hasIdentity(fingerprint: string): Promise<boolean>;
|
|
54
|
+
/**
|
|
55
|
+
* Creates a new identity and stores it securely.
|
|
56
|
+
*/
|
|
57
|
+
static createIdentity(passphrase: string): Promise<KeyStoreIdentity>;
|
|
58
|
+
/**
|
|
59
|
+
* Create a deterministic identity from a mnemonic and store it encrypted with passphrase.
|
|
60
|
+
* The identity `id` is set to the fingerprint for stable referencing.
|
|
61
|
+
*/
|
|
62
|
+
static createIdentityFromMnemonic(mnemonic: string, passphrase: string): Promise<KeyStoreIdentity>;
|
|
63
|
+
/**
|
|
64
|
+
* Unlocks a stored identity with the passphrase.
|
|
65
|
+
*/
|
|
66
|
+
static unlockIdentity(id: string, passphrase: string): Promise<KeyStoreIdentity>;
|
|
67
|
+
/**
|
|
68
|
+
* Get the private key of an unlocked identity by ID or fingerprint.
|
|
69
|
+
* Throws if the identity is not found or not unlocked.
|
|
70
|
+
*/
|
|
71
|
+
static getPrivateKey(idOrFingerprint: string): Promise<CryptoKey | {
|
|
72
|
+
raw: Uint8Array;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Locks a stored identity (removes private key from memory).
|
|
76
|
+
*/
|
|
77
|
+
static lockIdentity(identity: KeyStoreIdentity): Promise<KeyStoreIdentity>;
|
|
78
|
+
/**
|
|
79
|
+
* Gets a stored identity's public key.
|
|
80
|
+
*/
|
|
81
|
+
static getPublicKey(id: string): Promise<CryptoKey | {
|
|
82
|
+
raw: Uint8Array;
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Gets a stored identity's fingerprint.
|
|
86
|
+
*/
|
|
87
|
+
static getFingerprint(id: string): Promise<string>;
|
|
88
|
+
private static encryptPrivateKey;
|
|
89
|
+
private static decryptPrivateKey;
|
|
90
|
+
private static exportPublicKeyBase64;
|
|
91
|
+
private static importPublicKeyBase64;
|
|
92
|
+
/**
|
|
93
|
+
* Export a stored identity as a compact base64 backup blob (JSON -> base64).
|
|
94
|
+
* The exported blob contains the encrypted private key (already encrypted with user's passphrase)
|
|
95
|
+
* so the caller must preserve it securely. This is not a human-readable mnemonic.
|
|
96
|
+
*/
|
|
97
|
+
static exportIdentityBackup(id: string): Promise<string>;
|
|
98
|
+
/**
|
|
99
|
+
* Import an identity backup previously exported via `exportIdentityBackup`.
|
|
100
|
+
* This stores the serialized identity in IndexedDB. Caller can then call `unlockIdentity`.
|
|
101
|
+
*/
|
|
102
|
+
static importIdentityBackup(backupBase64: string): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* List all serialized identities stored in IndexedDB.
|
|
105
|
+
*/
|
|
106
|
+
static listStoredIdentities(): Promise<SerializedIdentity[]>;
|
|
107
|
+
/**
|
|
108
|
+
* Delete an identity by id from storage and in-memory caches.
|
|
109
|
+
*/
|
|
110
|
+
static deleteIdentity(id: string): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* Generate a BIP39 mnemonic (default 12 words / 128 bits entropy).
|
|
113
|
+
*/
|
|
114
|
+
static generateMnemonic(strength?: 128): string;
|
|
115
|
+
/**
|
|
116
|
+
* Export an identity encrypted with a mnemonic-derived key.
|
|
117
|
+
* Requires the identity to be unlocked in memory (privateKey available).
|
|
118
|
+
* Returns a base64 string containing iv+ciphertext and publicKey/fingerprint in JSON.
|
|
119
|
+
*/
|
|
120
|
+
static exportIdentityMnemonicBackup(id: string, mnemonic: string): Promise<string>;
|
|
121
|
+
/**
|
|
122
|
+
* Import an identity from a mnemonic-encrypted backup blob and store it encrypted with `passphrase`.
|
|
123
|
+
*/
|
|
124
|
+
static importIdentityFromMnemonicBackup(backupBase64: string, mnemonic: string, passphrase: string): Promise<KeyStoreIdentity>;
|
|
125
|
+
private static deriveKeyFromMnemonic;
|
|
126
|
+
}
|