slopus--happy
98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
229 行
8.2 KiB
TypeScript
229 行
8.2 KiB
TypeScript
import { deriveKey } from "@/encryption/deriveKey";
|
|
import { AES256Encryption, BoxEncryption, SecretBoxEncryption, Encryptor, Decryptor } from "./encryptor";
|
|
import { encodeHex } from "@/encryption/hex";
|
|
import { EncryptionCache } from "./encryptionCache";
|
|
import { SessionEncryption } from "./sessionEncryption";
|
|
import { MachineEncryption } from "./machineEncryption";
|
|
import { encodeBase64, decodeBase64 } from "@/encryption/base64";
|
|
import sodium from '@/encryption/libsodium.lib';
|
|
import { decryptBox, encryptBox } from "@/encryption/libsodium";
|
|
import { randomUUID } from 'expo-crypto';
|
|
|
|
export class Encryption {
|
|
|
|
static async create(masterSecret: Uint8Array) {
|
|
|
|
// Derive content data key to open session and machine records
|
|
const contentDataKey = await deriveKey(masterSecret, 'Happy EnCoder', ['content']);
|
|
|
|
// Derive content data key keypair
|
|
const contentKeyPair = sodium.crypto_box_seed_keypair(contentDataKey);
|
|
|
|
// Derive anonymous ID
|
|
const anonID = encodeHex((await deriveKey(masterSecret, 'Happy Coder', ['analytics', 'id']))).slice(0, 16).toLowerCase();
|
|
|
|
// Derive master blob key for legacy sessions (those with no per-session dataKey)
|
|
const masterBlobKey = await deriveKey(masterSecret, 'Happy Blobs', ['master']);
|
|
|
|
// Create encryption
|
|
return new Encryption(anonID, masterSecret, contentKeyPair, masterBlobKey);
|
|
}
|
|
|
|
private readonly legacyEncryption: SecretBoxEncryption;
|
|
private readonly contentKeyPair: sodium.KeyPair;
|
|
private readonly masterBlobKey: Uint8Array;
|
|
readonly anonID: string;
|
|
readonly contentDataKey: Uint8Array;
|
|
|
|
// Session and machine encryption management
|
|
private sessionEncryptions = new Map<string, SessionEncryption>();
|
|
private machineEncryptions = new Map<string, MachineEncryption>();
|
|
private sessionBlobKeys = new Map<string, Uint8Array>();
|
|
private cache: EncryptionCache;
|
|
|
|
private constructor(anonID: string, masterSecret: Uint8Array, contentKeyPair: sodium.KeyPair, masterBlobKey: Uint8Array) {
|
|
this.anonID = anonID;
|
|
this.contentKeyPair = contentKeyPair;
|
|
this.legacyEncryption = new SecretBoxEncryption(masterSecret);
|
|
this.masterBlobKey = masterBlobKey;
|
|
this.cache = new EncryptionCache();
|
|
this.contentDataKey = contentKeyPair.publicKey;
|
|
}
|
|
|
|
//
|
|
// Core encryption opening
|
|
//
|
|
|
|
async openEncryption(dataEncryptionKey: Uint8Array | null): Promise<Encryptor & Decryptor> {
|
|
if (!dataEncryptionKey) {
|
|
return this.legacyEncryption;
|
|
}
|
|
return new AES256Encryption(dataEncryptionKey);
|
|
}
|
|
|
|
//
|
|
// Session operations
|
|
//
|
|
|
|
/**
|
|
* Initialize sessions with their encryption keys
|
|
* This should be called once when sessions are loaded
|
|
*/
|
|
async initializeSessions(sessions: Map<string, Uint8Array | null>): Promise<void> {
|
|
for (const [sessionId, dataKey] of sessions) {
|
|
// Skip if already initialized
|
|
if (this.sessionEncryptions.has(sessionId)) {
|
|
continue;
|
|
}
|
|
|
|
// Create appropriate encryptor based on data key
|
|
const encryptor = await this.openEncryption(dataKey);
|
|
|
|
// Create and cache session encryption
|
|
const sessionEnc = new SessionEncryption(
|
|
sessionId,
|
|
encryptor,
|
|
this.cache
|
|
);
|
|
this.sessionEncryptions.set(sessionId, sessionEnc);
|
|
|
|
// Derive blob key for this session.
|
|
// Legacy sessions (null dataKey) use the master blob key.
|
|
// Newer sessions derive a subkey from their own dataKey so blobs
|
|
// are cryptographically isolated from message encryption.
|
|
const blobKey = dataKey
|
|
? await deriveKey(dataKey, 'Happy Blobs', ['session'])
|
|
: this.masterBlobKey;
|
|
this.sessionBlobKeys.set(sessionId, blobKey);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get session encryption if it has been initialized
|
|
* Returns null if not initialized (should never happen in normal flow)
|
|
*/
|
|
getSessionEncryption(sessionId: string): SessionEncryption | null {
|
|
return this.sessionEncryptions.get(sessionId) || null;
|
|
}
|
|
|
|
/**
|
|
* Remove session encryption from memory when session is deleted
|
|
*/
|
|
removeSessionEncryption(sessionId: string): void {
|
|
this.sessionEncryptions.delete(sessionId);
|
|
this.sessionBlobKeys.delete(sessionId);
|
|
// Also clear any cached data for this session
|
|
this.cache.clearSessionCache(sessionId);
|
|
}
|
|
|
|
/**
|
|
* Get the 32-byte NaCl secretbox key for encrypting binary blobs
|
|
* (image attachments) in a session. Distinct from the message encryption
|
|
* key to maintain cryptographic separation.
|
|
* Returns null if the session has not been initialized.
|
|
*/
|
|
getSessionBlobKey(sessionId: string): Uint8Array | null {
|
|
return this.sessionBlobKeys.get(sessionId) ?? null;
|
|
}
|
|
|
|
//
|
|
// Machine operations
|
|
//
|
|
|
|
/**
|
|
* Initialize machines with their encryption keys
|
|
* This should be called once when machines are loaded
|
|
*/
|
|
async initializeMachines(machines: Map<string, Uint8Array | null>): Promise<void> {
|
|
for (const [machineId, dataKey] of machines) {
|
|
// Skip if already initialized
|
|
if (this.machineEncryptions.has(machineId)) {
|
|
continue;
|
|
}
|
|
|
|
// Create appropriate encryptor based on data key
|
|
const encryptor = await this.openEncryption(dataKey);
|
|
|
|
// Create and cache machine encryption
|
|
const machineEnc = new MachineEncryption(
|
|
machineId,
|
|
encryptor,
|
|
this.cache
|
|
);
|
|
this.machineEncryptions.set(machineId, machineEnc);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get machine encryption if it has been initialized
|
|
* Returns null if not initialized (should never happen in normal flow)
|
|
*/
|
|
getMachineEncryption(machineId: string): MachineEncryption | null {
|
|
return this.machineEncryptions.get(machineId) || null;
|
|
}
|
|
|
|
/**
|
|
* Remove machine encryption from memory when the machine is deleted
|
|
*/
|
|
removeMachineEncryption(machineId: string): void {
|
|
this.machineEncryptions.delete(machineId);
|
|
}
|
|
|
|
//
|
|
// Legacy methods for machine metadata (temporary until machines are migrated)
|
|
//
|
|
|
|
async encryptRaw(data: any): Promise<string> {
|
|
const encrypted = await this.legacyEncryption.encrypt([data]);
|
|
return encodeBase64(encrypted[0], 'base64');
|
|
}
|
|
|
|
async decryptRaw(encrypted: string): Promise<any | null> {
|
|
try {
|
|
const encryptedData = decodeBase64(encrypted, 'base64');
|
|
const decrypted = await this.legacyEncryption.decrypt([encryptedData]);
|
|
return decrypted[0] || null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
//
|
|
// Data Encryption Key decryption
|
|
//
|
|
|
|
async decryptEncryptionKey(encrypted: string) {
|
|
// Never throw: callers (fetchMachines/fetchSessions/artifacts) iterate
|
|
// many keys, and an exception on one malformed/foreign key would
|
|
// reject the whole sync and silently drop every item. Always degrade
|
|
// to null so the caller can decide per-item.
|
|
try {
|
|
const encryptedKey = decodeBase64(encrypted, 'base64');
|
|
if (encryptedKey[0] !== 0) {
|
|
return null;
|
|
}
|
|
|
|
const decrypted = decryptBox(encryptedKey.slice(1), this.contentKeyPair.privateKey);
|
|
if (!decrypted) {
|
|
return null;
|
|
}
|
|
return decrypted;
|
|
} catch (error) {
|
|
console.error('decryptEncryptionKey failed:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async encryptEncryptionKey(key: Uint8Array): Promise<Uint8Array> {
|
|
// Use public key for encryption (encrypt TO ourselves)
|
|
const encrypted = encryptBox(key, this.contentKeyPair.publicKey);
|
|
const result = new Uint8Array(encrypted.length + 1);
|
|
result[0] = 0; // Version byte
|
|
result.set(encrypted, 1);
|
|
return result;
|
|
}
|
|
|
|
generateId(): string {
|
|
return randomUUID();
|
|
}
|
|
} |