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
-23
View File
@@ -1,23 +0,0 @@
import nacl from "tweetnacl";
import { hkdfExtractAndExpand } from "./kdf";
export interface X25519KeyPair {
publicKey: Uint8Array;
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<Uint8Array> {
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
}
-68
View File
@@ -1,68 +0,0 @@
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
import { importPassword, deriveKEK, randomBytes } from "./kdf";
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) };
}
+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);
}
-31
View File
@@ -1,31 +0,0 @@
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"]
);
}
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;
}
-34
View File
@@ -1,34 +0,0 @@
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> {
// 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);
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> {
// 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"]);
}