mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement standalone FromChat Protocol
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index";
|
||||
|
||||
export interface PrivateKeyBundle {
|
||||
version: 1;
|
||||
privateKey: Uint8Array; // X25519 private key
|
||||
}
|
||||
|
||||
export interface EncryptedBackupBlob {
|
||||
salt: Uint8Array; // for PBKDF2 derivation of KEK
|
||||
iv: Uint8Array; // AES-GCM IV
|
||||
ciphertext: Uint8Array; // encrypted serialized PrivateKeyBundle
|
||||
}
|
||||
|
||||
export function serializeBundle(bundle: PrivateKeyBundle): Uint8Array {
|
||||
const header = new Uint8Array([bundle.version]);
|
||||
const len = new Uint8Array(new Uint32Array([bundle.privateKey.length]).buffer);
|
||||
const out = new Uint8Array(1 + 4 + bundle.privateKey.length);
|
||||
out.set(header, 0);
|
||||
out.set(len, 1);
|
||||
out.set(bundle.privateKey, 5);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function deserializeBundle(data: Uint8Array): PrivateKeyBundle {
|
||||
const version = data[0] as 1;
|
||||
const len = new Uint32Array(data.slice(1, 5).buffer)[0];
|
||||
const pk = data.slice(5, 5 + len);
|
||||
return { version, privateKey: pk };
|
||||
}
|
||||
|
||||
export async function encryptBackupWithPassword(password: string, bundle: PrivateKeyBundle): Promise<EncryptedBackupBlob> {
|
||||
const salt = randomBytes(16);
|
||||
const pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, salt);
|
||||
const serialized = serializeBundle(bundle);
|
||||
const { iv, ciphertext } = await aesGcmEncrypt(kek, serialized);
|
||||
return { salt, iv, ciphertext };
|
||||
}
|
||||
|
||||
export async function decryptBackupWithPassword(password: string, blob: EncryptedBackupBlob): Promise<PrivateKeyBundle> {
|
||||
const pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, blob.salt);
|
||||
const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext);
|
||||
return deserializeBundle(plaintext);
|
||||
}
|
||||
|
||||
export function encodeBlob(blob: EncryptedBackupBlob): string {
|
||||
function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); }
|
||||
return JSON.stringify({
|
||||
salt: b64(blob.salt),
|
||||
iv: b64(blob.iv),
|
||||
ciphertext: b64(blob.ciphertext)
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeBlob(json: string): EncryptedBackupBlob {
|
||||
function ub64(s: string) {
|
||||
const bin = atob(s);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
const obj = JSON.parse(json);
|
||||
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import nacl from "tweetnacl";
|
||||
import { hkdfExtractAndExpand } from "./kdf";
|
||||
|
||||
export interface X25519KeyPair {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export function generateX25519KeyPair(): X25519KeyPair {
|
||||
const kp = nacl.box.keyPair();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.secretKey };
|
||||
}
|
||||
|
||||
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array {
|
||||
return nacl.box.before(theirPublicKey, myPrivateKey);
|
||||
}
|
||||
|
||||
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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> {
|
||||
const enc = new TextEncoder();
|
||||
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
|
||||
}
|
||||
|
||||
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
|
||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
||||
passwordKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface AesGcmCiphertext {
|
||||
iv: Uint8Array;
|
||||
ciphertext: Uint8Array;
|
||||
}
|
||||
|
||||
export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise<AesGcmCiphertext> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintextBuffer = plaintext instanceof Uint8Array ? plaintext.buffer as ArrayBuffer : plaintext;
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintextBuffer);
|
||||
return { iv, ciphertext: new Uint8Array(ct) };
|
||||
}
|
||||
|
||||
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
|
||||
const ivBuf: ArrayBuffer = iv instanceof Uint8Array
|
||||
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
|
||||
: (iv as ArrayBuffer);
|
||||
|
||||
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
|
||||
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
|
||||
: (ciphertext as ArrayBuffer);
|
||||
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuf }, key, ctBuf);
|
||||
return new Uint8Array(pt as ArrayBuffer);
|
||||
}
|
||||
|
||||
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
|
||||
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"]);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user