diff --git a/frontend/packages/fromchat-protocol/.gitignore b/frontend/packages/fromchat-protocol/.gitignore new file mode 100644 index 0000000..6e1dbeb --- /dev/null +++ b/frontend/packages/fromchat-protocol/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.DS_Store +package-lock.json + + diff --git a/frontend/packages/fromchat-protocol/.npmignore b/frontend/packages/fromchat-protocol/.npmignore new file mode 100644 index 0000000..606c52a --- /dev/null +++ b/frontend/packages/fromchat-protocol/.npmignore @@ -0,0 +1,8 @@ +src/ +tsconfig.json +node_modules/ +package-lock.json +*.log +.DS_Store + + diff --git a/frontend/packages/fromchat-protocol/PUBLISHING.md b/frontend/packages/fromchat-protocol/PUBLISHING.md new file mode 100644 index 0000000..03f24d1 --- /dev/null +++ b/frontend/packages/fromchat-protocol/PUBLISHING.md @@ -0,0 +1,171 @@ +# Publishing FromChat Protocol + +This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages. + +## Prerequisites + +1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup) +2. **GitHub account**: For GitHub Packages +3. **Node.js**: Version 18 or higher + +## Publishing to npm + +### Important: Scoped Package Setup + +The package uses the `@fromchat` scope. You have two options: + +**Option A: Create an npm organization (Recommended)** +1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create) +2. Create an organization named `fromchat` +3. Add yourself as a member +4. Then proceed with publishing below + +**Option B: Use unscoped package name** +If you prefer not to create an organization, change the package name in `package.json`: +```json +{ + "name": "fromchat-protocol" // Remove the @fromchat/ scope +} +``` +Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`. + +### 1. Build the package + +```bash +cd frontend/packages/fromchat-protocol +npm run build +``` + +This compiles TypeScript to JavaScript in the `dist/` directory. + +### 2. Login to npm + +```bash +npm login +``` + +Enter your npm username, password, and email. + +### 3. Publish + +**If using scoped package (`@fromchat/protocol`):** +```bash +npm publish --access public +``` + +**If using unscoped package (`fromchat-protocol`):** +```bash +npm publish +``` + +The `--access public` flag is required for scoped packages (packages starting with `@`). + +### 4. Verify + +Check your package at: `https://www.npmjs.com/package/@fromchat/protocol` + +### 5. Update version for future releases + +```bash +# Patch version (1.0.0 -> 1.0.1) +npm version patch + +# Minor version (1.0.0 -> 1.1.0) +npm version minor + +# Major version (1.0.0 -> 2.0.0) +npm version major + +# Then publish +npm publish --access public +``` + +## Publishing to GitHub Packages + +### 1. Create a GitHub Personal Access Token + +1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic) +2. Generate a new token with `write:packages` and `read:packages` permissions +3. Save the token securely + +### 2. Configure npm to use GitHub Packages + +Create or edit `~/.npmrc`: + +``` +@fromchat:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN +``` + +Or add to `package.json`: + +```json +{ + "publishConfig": { + "registry": "https://npm.pkg.github.com" + } +} +``` + +### 3. Update package.json + +Update the repository URL to match your GitHub repository: + +```json +{ + "repository": { + "type": "git", + "url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git", + "directory": "frontend/packages/fromchat-protocol" + } +} +``` + +### 4. Build and publish + +```bash +cd frontend/packages/fromchat-protocol +npm run build +npm publish +``` + +### 5. Install from GitHub Packages + +Users can install your package with: + +```bash +npm install @fromchat/protocol@npm:@fromchat/protocol +``` + +Or add to `.npmrc`: + +``` +@fromchat:registry=https://npm.pkg.github.com +``` + +## Using the Published Package + +### From npm + +```bash +npm install @fromchat/protocol +``` + +```typescript +import { FromChatProtocol } from "@fromchat/protocol"; +``` + +### From GitHub Packages + +```bash +npm install @fromchat/protocol@npm:@fromchat/protocol +``` + +## Notes + +- The package is built to `dist/` directory +- Source files in `src/` are excluded from the published package +- Only `dist/` and `README.md` are included in the published package +- The package uses ES modules (ESM) format +- TypeScript definitions are included in `dist/` + diff --git a/frontend/packages/fromchat-protocol/README.md b/frontend/packages/fromchat-protocol/README.md new file mode 100644 index 0000000..baa9d0f --- /dev/null +++ b/frontend/packages/fromchat-protocol/README.md @@ -0,0 +1,99 @@ +# FromChat Protocol + +Simple ECDH-based encryption protocol for direct messages. + +## Overview + +The FromChat Protocol provides end-to-end encryption for direct messages using: +- **X25519** (ECDH) for key exchange +- **HKDF** for key derivation +- **AES-GCM** for symmetric encryption + +This module is completely independent and can be used in any JavaScript/TypeScript project. + +## Protocol Flow + +### Encryption + +1. Generate a random message key (mk) - 32 bytes +2. Generate a random salt (wkSalt) - 16 bytes +3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)` +4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF +5. Encrypt message with mk using AES-GCM → (iv, ciphertext) +6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk) +7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }` + +### Decryption + +1. Derive shared secret from ECDH +2. Derive wrapping key from shared secret using salt from message +3. Decrypt wrappedMk to get mk +4. Decrypt ciphertext with mk + +## Usage + +```typescript +import { FromChatProtocol } from "@fromchat/protocol"; + +// Initialize with your private key +const protocol = new FromChatProtocol(privateKey); + +// Encrypt a message +const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!"); + +// Decrypt a message +const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted); +``` + +## API + +### `FromChatProtocol` + +#### Constructor +- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key + +#### Methods +- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise` - Encrypt a message +- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise` - Decrypt a message + +### Types + +```typescript +interface EncryptedMessage { + iv: string; // Base64 encoded IV for message encryption + ciphertext: string; // Base64 encoded encrypted message + salt: string; // Base64 encoded salt for wrapping key derivation + iv2: string; // Base64 encoded IV for message key wrapping + wrappedMk: string; // Base64 encoded wrapped message key +} +``` + +## Backup & Key Management + +The protocol also includes utilities for backing up and restoring private keys: + +```typescript +import { + encryptBackupWithPassword, + decryptBackupWithPassword, + encodeBlob, + decodeBlob +} from "@fromchat/protocol"; + +// Create a backup of a private key +const bundle = { version: 1, privateKey: myPrivateKey }; +const encrypted = await encryptBackupWithPassword("my-password", bundle); +const backupString = encodeBlob(encrypted); // Store this string + +// Restore from backup +const encryptedBlob = decodeBlob(backupString); +const restored = await decryptBackupWithPassword("my-password", encryptedBlob); +``` + +## Security Notes + +- Each message uses a fresh random message key +- The protocol does not provide forward secrecy +- Keys are derived using HKDF with SHA-256 +- All encryption uses AES-GCM with 12-byte IVs +- Backup encryption uses PBKDF2 with 210,000 iterations diff --git a/frontend/packages/fromchat-protocol/package.json b/frontend/packages/fromchat-protocol/package.json new file mode 100644 index 0000000..41082b8 --- /dev/null +++ b/frontend/packages/fromchat-protocol/package.json @@ -0,0 +1,54 @@ +{ + "name": "@fromchat/protocol", + "version": "1.0.0", + "description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "encryption", + "ecdh", + "e2ee", + "end-to-end-encryption", + "x25519", + "aes-gcm", + "hkdf" + ], + "author": "denis0001-dev", + "license": "GPL-3.0", + "repository": { + "type": "git", + "url": "https://github.com/Toolbox-io/FromChat.git", + "directory": "frontend/packages/fromchat-protocol" + }, + "bugs": { + "url": "https://github.com/Toolbox-io/FromChat/issues" + }, + "homepage": "https://github.com/Toolbox-io/FromChat#readme", + "dependencies": { + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=24.0.0" + } +} \ No newline at end of file diff --git a/frontend/src/utils/crypto/backup.ts b/frontend/packages/fromchat-protocol/src/backup/backup.ts similarity index 94% rename from frontend/src/utils/crypto/backup.ts rename to frontend/packages/fromchat-protocol/src/backup/backup.ts index da5d320..327b4c4 100644 --- a/frontend/src/utils/crypto/backup.ts +++ b/frontend/packages/fromchat-protocol/src/backup/backup.ts @@ -1,5 +1,4 @@ -import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; -import { importPassword, deriveKEK, randomBytes } from "./kdf"; +import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index"; export interface PrivateKeyBundle { version: 1; @@ -65,4 +64,3 @@ export function decodeBlob(json: string): EncryptedBackupBlob { return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) }; } - diff --git a/frontend/src/utils/crypto/asymmetric.ts b/frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts similarity index 82% rename from frontend/src/utils/crypto/asymmetric.ts rename to frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts index 72bf39f..35bde59 100644 --- a/frontend/src/utils/crypto/asymmetric.ts +++ b/frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts @@ -6,18 +6,16 @@ export interface X25519KeyPair { privateKey: Uint8Array; } -export type KeyPair = X25519KeyPair; - export function generateX25519KeyPair(): X25519KeyPair { const kp = nacl.box.keyPair(); return { publicKey: kp.publicKey, privateKey: kp.secretKey }; } export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { - // nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF. return nacl.box.before(theirPublicKey, myPrivateKey); } export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise { return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32); -} \ No newline at end of file +} + diff --git a/frontend/packages/fromchat-protocol/src/crypto/index.ts b/frontend/packages/fromchat-protocol/src/crypto/index.ts new file mode 100644 index 0000000..49a1b0b --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/crypto/index.ts @@ -0,0 +1,7 @@ +// Re-export all crypto functions for convenience +export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./asymmetric"; +export type { X25519KeyPair } from "./asymmetric"; +export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./symmetric"; +export type { AesGcmCiphertext } from "./symmetric"; +export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./kdf"; + diff --git a/frontend/src/utils/crypto/kdf.ts b/frontend/packages/fromchat-protocol/src/crypto/kdf.ts similarity index 99% rename from frontend/src/utils/crypto/kdf.ts rename to frontend/packages/fromchat-protocol/src/crypto/kdf.ts index b58a133..158e272 100644 --- a/frontend/src/utils/crypto/kdf.ts +++ b/frontend/packages/fromchat-protocol/src/crypto/kdf.ts @@ -1,3 +1,19 @@ +export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise { + const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial; + const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; + const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info; + + const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8); + return new Uint8Array(bits); +} + +export function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + crypto.getRandomValues(out); + return out; +} + export async function importPassword(password: string): Promise { const enc = new TextEncoder(); return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); @@ -13,19 +29,3 @@ export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | Array ["encrypt", "decrypt"] ); } - -export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise { - const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial; - const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; - const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info; - - const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]); - const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8); - return new Uint8Array(bits); -} - -export function randomBytes(length: number): Uint8Array { - const out = new Uint8Array(length); - crypto.getRandomValues(out); - return out; -} \ No newline at end of file diff --git a/frontend/src/utils/crypto/symmetric.ts b/frontend/packages/fromchat-protocol/src/crypto/symmetric.ts similarity index 88% rename from frontend/src/utils/crypto/symmetric.ts rename to frontend/packages/fromchat-protocol/src/crypto/symmetric.ts index 804680b..4822392 100644 --- a/frontend/src/utils/crypto/symmetric.ts +++ b/frontend/packages/fromchat-protocol/src/crypto/symmetric.ts @@ -11,12 +11,10 @@ export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | Arra } export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise { - // Normalize IV to ArrayBuffer (12 bytes for AES-GCM) const ivBuf: ArrayBuffer = iv instanceof Uint8Array ? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength) : (iv as ArrayBuffer); - // Normalize ciphertext to a contiguous ArrayBuffer slice const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array ? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) : (ciphertext as ArrayBuffer); @@ -26,9 +24,8 @@ export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer } export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise { - // Normalize to a contiguous ArrayBuffer slice to avoid offset/length issues const keyBuffer = rawKey instanceof Uint8Array ? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) : (rawKey as ArrayBuffer); return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); -} \ No newline at end of file +} diff --git a/frontend/packages/fromchat-protocol/src/index.ts b/frontend/packages/fromchat-protocol/src/index.ts new file mode 100644 index 0000000..ddc271c --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/index.ts @@ -0,0 +1,20 @@ +export { FromChatProtocol } from "./protocol/FromChatProtocol"; +export type { EncryptedMessage } from "./protocol/types"; + +// Export crypto functions +export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./crypto/asymmetric"; +export type { X25519KeyPair } from "./crypto/asymmetric"; +export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./crypto/symmetric"; +export type { AesGcmCiphertext } from "./crypto/symmetric"; +export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./crypto/kdf"; + +// Export backup functions +export { + encryptBackupWithPassword, + decryptBackupWithPassword, + encodeBlob, + decodeBlob, + serializeBundle, + deserializeBundle +} from "./backup/backup"; +export type { PrivateKeyBundle, EncryptedBackupBlob } from "./backup/backup"; diff --git a/frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts b/frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts new file mode 100644 index 0000000..88bef98 --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts @@ -0,0 +1,102 @@ +import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; +import { randomBytes } from "../crypto/kdf"; +import type { EncryptedMessage } from "./types"; + +/** + * FromChat Protocol - Simple ECDH-based encryption + * + * Protocol: + * 1. Generate random message key (mk) - 32 bytes + * 2. Generate random salt (wkSalt) - 16 bytes + * 3. Derive shared secret from ECDH (X25519) + * 4. Derive wrapping key from shared secret using HKDF with salt + * 5. Encrypt message with mk using AES-GCM + * 6. Encrypt (wrap) mk with wrapping key using AES-GCM + * 7. Send: { iv, ciphertext, salt, iv2, wrappedMk } + */ +export class FromChatProtocol { + private privateKey: Uint8Array; + + constructor(privateKey: Uint8Array) { + this.privateKey = privateKey; + } + + /** + * Encrypt a message for a recipient + * @param recipientPublicKey - Recipient's X25519 public key + * @param plaintext - Message to encrypt + * @returns Encrypted message with all necessary fields + */ + async encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise { + // Generate random message key + const mk = randomBytes(32); + + // Generate random salt for wrapping key derivation + const wkSalt = randomBytes(16); + + // Derive shared secret from ECDH + const shared = ecdhSharedSecret(this.privateKey, recipientPublicKey); + + // Derive wrapping key from shared secret using HKDF + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Encrypt the message with message key + const plaintextBytes = new TextEncoder().encode(plaintext); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), plaintextBytes); + + // Encrypt (wrap) the message key with wrapping key + const wrap = await aesGcmEncrypt(wk, mk); + + // Convert to base64 for transmission + return { + iv: btoa(String.fromCharCode(...encMsg.iv)), + ciphertext: btoa(String.fromCharCode(...encMsg.ciphertext)), + salt: btoa(String.fromCharCode(...wkSalt)), + iv2: btoa(String.fromCharCode(...wrap.iv)), + wrappedMk: btoa(String.fromCharCode(...wrap.ciphertext)) + }; + } + + /** + * Decrypt a message from a sender + * @param senderPublicKey - Sender's X25519 public key + * @param message - Encrypted message + * @returns Decrypted plaintext + */ + async decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise { + // Decode base64 fields + const salt = new Uint8Array( + atob(message.salt).split("").map(c => c.charCodeAt(0)) + ); + const iv2 = new Uint8Array( + atob(message.iv2).split("").map(c => c.charCodeAt(0)) + ); + const wrappedMk = new Uint8Array( + atob(message.wrappedMk).split("").map(c => c.charCodeAt(0)) + ); + const iv = new Uint8Array( + atob(message.iv).split("").map(c => c.charCodeAt(0)) + ); + const ciphertext = new Uint8Array( + atob(message.ciphertext).split("").map(c => c.charCodeAt(0)) + ); + + // Derive shared secret from ECDH + const shared = ecdhSharedSecret(this.privateKey, senderPublicKey); + + // Derive wrapping key from shared secret using salt from message + const wkRaw = await deriveWrappingKey(shared, salt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Decrypt (unwrap) the message key + const mk = await aesGcmDecrypt(wk, iv2, wrappedMk); + + // Decrypt the message with message key + const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext); + + return new TextDecoder().decode(decrypted); + } +} + diff --git a/frontend/packages/fromchat-protocol/src/protocol/types.ts b/frontend/packages/fromchat-protocol/src/protocol/types.ts new file mode 100644 index 0000000..049250e --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/protocol/types.ts @@ -0,0 +1,10 @@ +/** + * Encrypted message format + */ +export interface EncryptedMessage { + iv: string; // Base64 encoded IV for message encryption + ciphertext: string; // Base64 encoded encrypted message + salt: string; // Base64 encoded salt for wrapping key derivation + iv2: string; // Base64 encoded IV for message key wrapping + wrappedMk: string; // Base64 encoded wrapped message key +} diff --git a/frontend/packages/fromchat-protocol/tsconfig.json b/frontend/packages/fromchat-protocol/tsconfig.json new file mode 100644 index 0000000..6ac621c --- /dev/null +++ b/frontend/packages/fromchat-protocol/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/src/core/api/account/index.ts b/frontend/src/core/api/account/index.ts index af2318b..9b9fdbc 100644 --- a/frontend/src/core/api/account/index.ts +++ b/frontend/src/core/api/account/index.ts @@ -1,9 +1,7 @@ import { API_BASE_URL } from "@/core/config"; import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types"; -import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; -import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; +import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol"; import { b64, ub64 } from "@/utils/utils"; -import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; import type { Headers } from "@/core/types"; diff --git a/frontend/src/core/api/chats/dm.ts b/frontend/src/core/api/chats/dm.ts index 98dc1c5..1feb515 100644 --- a/frontend/src/core/api/chats/dm.ts +++ b/frontend/src/core/api/chats/dm.ts @@ -1,28 +1,19 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "../user/auth"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; import { getCurrentKeys } from "../user/auth"; import { request } from "@/core/websocket"; -import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types"; import { b64, ub64 } from "@/utils/utils"; import { fetchUserPublicKey } from "../crypto/identity"; import { fetchUsers, searchUsers } from "../user/search"; +import { getOrInitProtocol } from "@/utils/crypto/fromchatInit"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol"; export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // Obtain the key - const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); - - // Decrypt - const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); - return new TextDecoder().decode(msg); + const protocol = getOrInitProtocol(); + const senderPublicKey = ub64(senderPublicKeyB64); + + return await protocol.decryptMessage(senderPublicKey, envelope); } export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> { @@ -39,27 +30,14 @@ export async function fetchMessages(userId: number, token: string, limit: number } export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // Encryption key - const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - // Encrypt the message - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); - const wrap = await aesGcmEncrypt(wk, mk); - + const protocol = getOrInitProtocol(); + const recipientPublicKey = ub64(recipientPublicKeyB64); + + const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext); + const payload: SendDMRequest = { recipientId: recipientId, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) + ...encrypted }; if (replyToId) payload.replyToId = replyToId; @@ -74,15 +52,16 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p } export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { + // For files, we need to use the same message key for both the message and files + // So we'll do the encryption manually here to reuse the mk const keys = getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); const mk = randomBytes(32); const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const wk = await importAesGcmKey(wkRaw); - const wrap = await aesGcmEncrypt(wk, mk); const form = new FormData(); @@ -96,21 +75,14 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: const data = new Uint8Array(await f.arrayBuffer()); const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); - const serverName = f.name; // server uses provided name + const serverName = f.name; names.push(serverName); form.append("files", new File([blob], serverName)); } form.append("fileNames", JSON.stringify(names)); - // Merge files metadata into plaintext JSON and encrypt - let obj: DmEncryptedJSON; - try { - obj = JSON.parse(plaintextJson); - } catch { - obj = { type: "text", data: { content: String(plaintextJson) } }; - } - - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj))); + // Encrypt the plaintext JSON with the same mk + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson)); form.append("dm_payload", JSON.stringify({ recipientId: recipientId, iv: b64(encMsg.iv), @@ -128,28 +100,17 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: } export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap - const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); - const wrap = await aesGcmEncrypt(wk, mk); + const protocol = getOrInitProtocol(); + const recipientPublicKey = ub64(recipientPublicKeyB64); + + const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson); await request({ type: "dmEdit", credentials: { scheme: "Bearer", credentials: authToken }, data: { id, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext), - salt: b64(wkSalt) + ...encrypted } } as DMEditRequest); } @@ -189,6 +150,4 @@ export async function markRead(id: number, authToken: string): Promise { } // Re-export user functions for convenience -export { fetchUsers, searchUsers, fetchUserPublicKey }; - - +export { fetchUsers, searchUsers, fetchUserPublicKey }; \ No newline at end of file diff --git a/frontend/src/core/api/crypto/backup.ts b/frontend/src/core/api/crypto/backup.ts index 3354c5c..bfa2d54 100644 --- a/frontend/src/core/api/crypto/backup.ts +++ b/frontend/src/core/api/crypto/backup.ts @@ -1,12 +1,12 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "../user/auth"; import type { BackupBlob } from "@/core/types"; +import api from "@/core/api"; /** * Fetches the current user's backup blob */ export async function fetchBackupBlob(token: string): Promise { - const headers = getAuthHeaders(token, true); + const headers = api.user.auth.getAuthHeaders(token, true); const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "GET", headers @@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise { export async function uploadBackupBlob(blobJson: string, token: string): Promise { const payload: BackupBlob = { blob: blobJson } - const headers = getAuthHeaders(token, true); + const headers = api.user.auth.getAuthHeaders(token, true); const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "POST", headers, diff --git a/frontend/src/core/api/dm.ts b/frontend/src/core/api/dm.ts index b0cc194..7235c81 100644 --- a/frontend/src/core/api/dm.ts +++ b/frontend/src/core/api/dm.ts @@ -1,8 +1,6 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "./account"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol"; import { getCurrentKeys } from "./account"; import { request } from "@/core/websocket"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index b0cc194..7235c81 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,8 +1,6 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "./account"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol"; import { getCurrentKeys } from "./account"; import { request } from "@/core/websocket"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; diff --git a/frontend/src/core/api/user/auth.ts b/frontend/src/core/api/user/auth.ts index 80b131a..adc656b 100644 --- a/frontend/src/core/api/user/auth.ts +++ b/frontend/src/core/api/user/auth.ts @@ -1,9 +1,7 @@ import { API_BASE_URL } from "@/core/config"; import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types"; -import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; -import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; +import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol"; import { b64, ub64 } from "@/utils/utils"; -import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; import { fetchPublicKey, uploadPublicKey } from "../crypto/identity"; import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup"; diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index 9ebc700..cf61801 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -1,7 +1,5 @@ -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol"; import { b64, ub64 } from "@/utils/utils"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import api from "@/core/api"; import type { WrappedSessionKeyPayload } from "@/core/types"; diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index dbacf14..1edc0f8 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -2,7 +2,7 @@ import api from "@/core/api"; import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; -import { importAesGcmKey } from "@/utils/crypto/symmetric"; +import { importAesGcmKey } from "@fromchat/protocol"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 012629c..8d9b688 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -6,8 +6,7 @@ import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; import api from "@/core/api"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; diff --git a/frontend/src/utils/crypto/fromchatInit.ts b/frontend/src/utils/crypto/fromchatInit.ts new file mode 100644 index 0000000..6e1e617 --- /dev/null +++ b/frontend/src/utils/crypto/fromchatInit.ts @@ -0,0 +1,26 @@ +import { FromChatProtocol } from "@fromchat/protocol"; +import { getCurrentKeys } from "@/core/api/user/auth"; + +let protocolInstance: FromChatProtocol | null = null; + +export function getFromChatProtocol(): FromChatProtocol | null { + return protocolInstance; +} + +export function initializeFromChatProtocol(privateKey: Uint8Array): FromChatProtocol { + protocolInstance = new FromChatProtocol(privateKey); + return protocolInstance; +} + +export function getOrInitProtocol(): FromChatProtocol { + if (protocolInstance) { + return protocolInstance; + } + + const keys = getCurrentKeys(); + if (!keys) { + throw new Error("Keys not initialized"); + } + + return initializeFromChatProtocol(keys.privateKey); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4189505..9efcafb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,10 +14,11 @@ "noEmit": true, /* Path mapping */ - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - }, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@fromchat/protocol": ["./packages/fromchat-protocol/src"] + }, /* Linting */ "strict": true, @@ -31,6 +32,6 @@ "jsx": "react-jsx", "jsxImportSource": "react" }, - "include": ["src", "electron.d.ts"], + "include": ["src", "electron.d.ts", "packages/fromchat-protocol/src"], "exclude": ["**/__*/**", "__*"] } \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 719e614..32018c9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -70,7 +70,8 @@ export default defineConfig({ plugins: plugins, resolve: { alias: { - "@": path.resolve(__dirname, "./src") + "@": path.resolve(__dirname, "./src"), + "@fromchat/protocol": path.resolve(__dirname, "./packages/fromchat-protocol/src/index.ts") } }, server: { @@ -89,6 +90,7 @@ export default defineConfig({ }, appType: "spa", optimizeDeps: { + exclude: ["@fromchat/protocol"], esbuildOptions: { target: "es2022" } diff --git a/package.json b/package.json index b679225..56dc600 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,11 @@ "vite-plugin-html": "^3.2.2", "vite-plugin-sass-dts": "^1.3.34" }, + "workspaces": [ + "frontend/packages/fromchat-protocol" + ], "dependencies": { + "@fromchat/protocol": "workspace:*", "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", "he": "^1.2.0",