Implement standalone FromChat Protocol

This commit is contained in:
2025-12-04 23:14:01 +03:00
Unverified
parent 96cba60804
commit cc29d2d546
27 changed files with 592 additions and 120 deletions
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.DS_Store
package-lock.json
@@ -0,0 +1,8 @@
src/
tsconfig.json
node_modules/
package-lock.json
*.log
.DS_Store
@@ -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/`
@@ -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<EncryptedMessage>` - Encrypt a message
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - 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
@@ -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"
}
}
@@ -1,5 +1,4 @@
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index";
import { importPassword, deriveKEK, randomBytes } from "./kdf";
export interface PrivateKeyBundle { export interface PrivateKeyBundle {
version: 1; 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) }; return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
} }
@@ -6,18 +6,16 @@ export interface X25519KeyPair {
privateKey: Uint8Array; privateKey: Uint8Array;
} }
export type KeyPair = X25519KeyPair;
export function generateX25519KeyPair(): X25519KeyPair { export function generateX25519KeyPair(): X25519KeyPair {
const kp = nacl.box.keyPair(); const kp = nacl.box.keyPair();
return { publicKey: kp.publicKey, privateKey: kp.secretKey }; return { publicKey: kp.publicKey, privateKey: kp.secretKey };
} }
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { 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); return nacl.box.before(theirPublicKey, myPrivateKey);
} }
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> { export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32); return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
} }
@@ -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";
@@ -1,3 +1,19 @@
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
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<CryptoKey> { export async function importPassword(password: string): Promise<CryptoKey> {
const enc = new TextEncoder(); const enc = new TextEncoder();
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); 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"] ["encrypt", "decrypt"]
); );
} }
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
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;
}
@@ -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<Uint8Array> { export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
const ivBuf: ArrayBuffer = iv instanceof Uint8Array const ivBuf: ArrayBuffer = iv instanceof Uint8Array
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength) ? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
: (iv as ArrayBuffer); : (iv as ArrayBuffer);
// Normalize ciphertext to a contiguous ArrayBuffer slice
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) ? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
: (ciphertext as ArrayBuffer); : (ciphertext as ArrayBuffer);
@@ -26,9 +24,8 @@ export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer
} }
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> { export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
// Normalize to a contiguous ArrayBuffer slice to avoid offset/length issues
const keyBuffer = rawKey instanceof Uint8Array const keyBuffer = rawKey instanceof Uint8Array
? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) ? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength)
: (rawKey as ArrayBuffer); : (rawKey as ArrayBuffer);
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
} }
@@ -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";
@@ -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<EncryptedMessage> {
// 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<string> {
// 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);
}
}
@@ -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
}
@@ -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"]
}
+1 -3
View File
@@ -1,9 +1,7 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types"; import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
import type { Headers } from "@/core/types"; import type { Headers } from "@/core/types";
+25 -66
View File
@@ -1,28 +1,19 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth"; 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 { getCurrentKeys } from "../user/auth";
import { request } from "@/core/websocket"; 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 { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity"; import { fetchUserPublicKey } from "../crypto/identity";
import { fetchUsers, searchUsers } from "../user/search"; 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<string> { export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys(); const protocol = getOrInitProtocol();
if (!keys) throw new Error("Keys not initialized"); const senderPublicKey = ub64(senderPublicKeyB64);
// Obtain the key return await protocol.decryptMessage(senderPublicKey, envelope);
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);
} }
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> { 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<void> { export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys(); const protocol = getOrInitProtocol();
if (!keys) throw new Error("Keys not initialized"); const recipientPublicKey = ub64(recipientPublicKeyB64);
// Encryption key const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
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 payload: SendDMRequest = { const payload: SendDMRequest = {
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), ...encrypted
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
}; };
if (replyToId) payload.replyToId = replyToId; 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<void> { export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
// 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(); const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
const mk = randomBytes(32); const mk = randomBytes(32);
const wkSalt = randomBytes(16); 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 wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw); const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk); const wrap = await aesGcmEncrypt(wk, mk);
const form = new FormData(); const form = new FormData();
@@ -96,21 +75,14 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
const data = new Uint8Array(await f.arrayBuffer()); const data = new Uint8Array(await f.arrayBuffer());
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); 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); names.push(serverName);
form.append("files", new File([blob], serverName)); form.append("files", new File([blob], serverName));
} }
form.append("fileNames", JSON.stringify(names)); form.append("fileNames", JSON.stringify(names));
// Merge files metadata into plaintext JSON and encrypt // Encrypt the plaintext JSON with the same mk
let obj: DmEncryptedJSON; const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
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)));
form.append("dm_payload", JSON.stringify({ form.append("dm_payload", JSON.stringify({
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), 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<void> { export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys(); const protocol = getOrInitProtocol();
if (!keys) throw new Error("Keys not initialized"); const recipientPublicKey = ub64(recipientPublicKeyB64);
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
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);
await request({ await request({
type: "dmEdit", type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken }, credentials: { scheme: "Bearer", credentials: authToken },
data: { data: {
id, id,
iv: b64(encMsg.iv), ...encrypted
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
} }
} as DMEditRequest); } as DMEditRequest);
} }
@@ -189,6 +150,4 @@ export async function markRead(id: number, authToken: string): Promise<void> {
} }
// Re-export user functions for convenience // Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey }; export { fetchUsers, searchUsers, fetchUserPublicKey };
+3 -3
View File
@@ -1,12 +1,12 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { BackupBlob } from "@/core/types"; import type { BackupBlob } from "@/core/types";
import api from "@/core/api";
/** /**
* Fetches the current user's backup blob * Fetches the current user's backup blob
*/ */
export async function fetchBackupBlob(token: string): Promise<string | null> { export async function fetchBackupBlob(token: string): Promise<string | null> {
const headers = getAuthHeaders(token, true); const headers = api.user.auth.getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, { const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET", method: "GET",
headers headers
@@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise<string | null> {
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> { export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson } 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`, { const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST", method: "POST",
headers, headers,
+1 -3
View File
@@ -1,8 +1,6 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./account"; import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
+1 -3
View File
@@ -1,8 +1,6 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./account"; import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
+1 -3
View File
@@ -1,9 +1,7 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types"; import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity"; import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup"; import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
+1 -3
View File
@@ -1,7 +1,5 @@
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol";
import { randomBytes } from "@/utils/crypto/kdf";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import api from "@/core/api"; import api from "@/core/api";
import type { WrappedSessionKeyPayload } from "@/core/types"; import type { WrappedSessionKeyPayload } from "@/core/types";
+1 -1
View File
@@ -2,7 +2,7 @@ import api from "@/core/api";
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
import { importAesGcmKey } from "@/utils/crypto/symmetric"; import { importAesGcmKey } from "@fromchat/protocol";
import E2EEWorker from "./e2eeWorker?worker"; import E2EEWorker from "./e2eeWorker?worker";
import { delay } from "@/utils/utils"; import { delay } from "@/utils/utils";
+1 -2
View File
@@ -6,8 +6,7 @@ import { parse } from "marked";
import { escape as escapeHtml } from "he"; import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react"; import { useEffect, useState, useRef, useMemo } from "react";
import api from "@/core/api"; import api from "@/core/api";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile"; import { useProfileStore } from "@/state/profile";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
+26
View File
@@ -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);
}
+6 -5
View File
@@ -14,10 +14,11 @@
"noEmit": true, "noEmit": true,
/* Path mapping */ /* Path mapping */
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"],
}, "@fromchat/protocol": ["./packages/fromchat-protocol/src"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
@@ -31,6 +32,6 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react"
}, },
"include": ["src", "electron.d.ts"], "include": ["src", "electron.d.ts", "packages/fromchat-protocol/src"],
"exclude": ["**/__*/**", "__*"] "exclude": ["**/__*/**", "__*"]
} }
+3 -1
View File
@@ -70,7 +70,8 @@ export default defineConfig({
plugins: plugins, plugins: plugins,
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src") "@": path.resolve(__dirname, "./src"),
"@fromchat/protocol": path.resolve(__dirname, "./packages/fromchat-protocol/src/index.ts")
} }
}, },
server: { server: {
@@ -89,6 +90,7 @@ export default defineConfig({
}, },
appType: "spa", appType: "spa",
optimizeDeps: { optimizeDeps: {
exclude: ["@fromchat/protocol"],
esbuildOptions: { esbuildOptions: {
target: "es2022" target: "es2022"
} }
+4
View File
@@ -67,7 +67,11 @@
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2",
"vite-plugin-sass-dts": "^1.3.34" "vite-plugin-sass-dts": "^1.3.34"
}, },
"workspaces": [
"frontend/packages/fromchat-protocol"
],
"dependencies": { "dependencies": {
"@fromchat/protocol": "workspace:*",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"he": "^1.2.0", "he": "^1.2.0",