Files
NetMesh/infrastructure/services/EncryptionService.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

451 lines
12 KiB
TypeScript

/**
* EncryptionService - Zero-Knowledge Encryption for Cloud Sync
*
* Implements AES-256-GCM encryption with PBKDF2 key derivation.
* All encryption/decryption happens client-side; cloud providers never see plaintext.
*
* Security Model:
* - Master password → PBKDF2 (600k iterations) → AES-256 key
* - Each sync file has unique IV and salt
* - Key verification via hash comparison (not by storing the key)
*/
import {
SYNC_CONSTANTS,
type EncryptionResult,
type DecryptionInput,
type MasterKeyConfig,
type UnlockedMasterKey,
type SyncedFile,
type SyncFileMeta,
type SyncPayload,
} from '../../domain/sync';
import { validateConvergentSyncPayload } from '../../domain/convergentSync';
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Convert Uint8Array to ArrayBuffer for Web Crypto API compatibility
* TypeScript 5.x requires explicit conversion from Uint8Array<ArrayBufferLike> to BufferSource
*/
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
};
/**
* Convert ArrayBuffer to Base64 string
*/
export const arrayBufferToBase64 = (buffer: ArrayBuffer | Uint8Array): string => {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
};
/**
* Convert Base64 string to Uint8Array
*/
export const base64ToUint8Array = (base64: string): Uint8Array => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
/**
* Generate cryptographically secure random bytes
*/
export const generateRandomBytes = (length: number): Uint8Array => {
return crypto.getRandomValues(new Uint8Array(length));
};
/**
* Compute SHA-256 hash of data
*/
export const sha256 = async (data: Uint8Array): Promise<Uint8Array> => {
const hashBuffer = await crypto.subtle.digest('SHA-256', toArrayBuffer(data));
return new Uint8Array(hashBuffer);
};
/**
* Convert string to Uint8Array using UTF-8 encoding
*/
const stringToBytes = (str: string): Uint8Array => {
return new TextEncoder().encode(str);
};
/**
* Convert Uint8Array to string using UTF-8 decoding
*/
const bytesToString = (bytes: Uint8Array): string => {
return new TextDecoder().decode(bytes);
};
// ============================================================================
// Key Derivation
// ============================================================================
/**
* Derive an AES-256 key from password using PBKDF2
*
* @param password - User's master password
* @param salt - Random salt (32 bytes recommended)
* @param iterations - PBKDF2 iterations (600000 recommended)
* @returns CryptoKey suitable for AES-256-GCM operations
*/
export const deriveKey = async (
password: string,
salt: Uint8Array,
iterations: number = SYNC_CONSTANTS.PBKDF2_ITERATIONS
): Promise<CryptoKey> => {
// Import password as key material
const passwordKey = await crypto.subtle.importKey(
'raw',
toArrayBuffer(stringToBytes(password)),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
// Derive AES key using PBKDF2
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: toArrayBuffer(salt),
iterations: iterations,
hash: SYNC_CONSTANTS.PBKDF2_HASH,
},
passwordKey,
{
name: 'AES-GCM',
length: SYNC_CONSTANTS.AES_KEY_LENGTH,
},
true, // extractable for verification
['encrypt', 'decrypt']
);
return derivedKey;
};
/**
* Export CryptoKey to raw bytes for verification purposes
*/
export const exportKey = async (key: CryptoKey): Promise<Uint8Array> => {
const exported = await crypto.subtle.exportKey('raw', key);
return new Uint8Array(exported);
};
/**
* Create a verification hash from derived key
* Used to verify correct password without storing the key
*/
export const createVerificationHash = async (derivedKey: CryptoKey): Promise<string> => {
const keyBytes = await exportKey(derivedKey);
const hash = await sha256(keyBytes);
return arrayBufferToBase64(hash);
};
/**
* Verify that a password produces the expected verification hash
*/
export const verifyPassword = async (
password: string,
config: MasterKeyConfig
): Promise<boolean> => {
try {
const salt = base64ToUint8Array(config.salt);
const derivedKey = await deriveKey(
password,
salt,
config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
const hash = await createVerificationHash(derivedKey);
return hash === config.verificationHash;
} catch {
return false;
}
};
// ============================================================================
// Encryption / Decryption
// ============================================================================
/**
* Encrypt plaintext using AES-256-GCM
*
* @param plaintext - Data to encrypt (as string)
* @param key - AES-256 CryptoKey
* @param salt - Salt used for key derivation (stored in result)
* @returns Encrypted data with IV
*/
export const encrypt = async (
plaintext: string,
key: CryptoKey,
salt: Uint8Array
): Promise<EncryptionResult> => {
// Generate random IV
const iv = generateRandomBytes(SYNC_CONSTANTS.GCM_IV_LENGTH);
// Encrypt
const ciphertextBuffer = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(iv),
tagLength: SYNC_CONSTANTS.GCM_TAG_LENGTH,
},
key,
toArrayBuffer(stringToBytes(plaintext))
);
return {
ciphertext: new Uint8Array(ciphertextBuffer),
iv: iv,
salt: salt,
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
kdfIterations: SYNC_CONSTANTS.PBKDF2_ITERATIONS,
};
};
/**
* Decrypt ciphertext using AES-256-GCM
*
* @param input - Encrypted data with IV
* @param key - AES-256 CryptoKey
* @returns Decrypted plaintext
*/
export const decrypt = async (
input: DecryptionInput,
key: CryptoKey
): Promise<string> => {
const plaintextBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(input.iv),
tagLength: SYNC_CONSTANTS.GCM_TAG_LENGTH,
},
key,
toArrayBuffer(input.ciphertext)
);
return bytesToString(new Uint8Array(plaintextBuffer));
};
// ============================================================================
// High-Level Encryption API
// ============================================================================
/**
* Encrypt a sync payload to create a SyncedFile
*
* @param payload - Data to encrypt
* @param password - Master password
* @param deviceId - Device identifier
* @param appVersion - App version string
* @returns Complete SyncedFile ready for upload
*/
export const encryptPayload = async (
payload: SyncPayload,
password: string,
deviceId: string,
deviceName: string,
appVersion: string,
existingVersion?: number
): Promise<SyncedFile> => {
const syncSchemaVersion = payload.convergentSync?.schemaVersion;
if (syncSchemaVersion !== undefined) {
validateConvergentSyncPayload(
{ syncSchemaVersion },
payload,
);
}
// Generate new salt for each encryption
const salt = generateRandomBytes(SYNC_CONSTANTS.SALT_LENGTH);
// Derive key from password
const key = await deriveKey(password, salt);
// Encrypt the payload
const plaintext = JSON.stringify(payload);
const encrypted = await encrypt(plaintext, key, salt);
// Create metadata
const meta: SyncFileMeta = {
version: (existingVersion || 0) + 1,
updatedAt: Date.now(),
deviceId: deviceId,
deviceName: deviceName,
appVersion: appVersion,
iv: arrayBufferToBase64(encrypted.iv),
salt: arrayBufferToBase64(encrypted.salt),
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
kdfIterations: SYNC_CONSTANTS.PBKDF2_ITERATIONS,
...(syncSchemaVersion ? { syncSchemaVersion } : {}),
};
return {
meta,
payload: arrayBufferToBase64(encrypted.ciphertext),
};
};
/**
* Decrypt a SyncedFile to retrieve the payload
*
* @param syncedFile - Encrypted file from cloud
* @param password - Master password
* @returns Decrypted payload
*/
export const decryptPayload = async (
syncedFile: SyncedFile,
password: string
): Promise<SyncPayload> => {
const { meta, payload } = syncedFile;
// Decode Base64 values
const salt = base64ToUint8Array(meta.salt);
const iv = base64ToUint8Array(meta.iv);
const ciphertext = base64ToUint8Array(payload);
// Derive key from password
const key = await deriveKey(
password,
salt,
meta.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
// Decrypt
const decrypted = await decrypt(
{ ciphertext, iv, salt, kdf: meta.kdf, kdfIterations: meta.kdfIterations },
key
);
const parsed = JSON.parse(decrypted) as SyncPayload;
validateConvergentSyncPayload(meta, parsed);
return parsed;
};
/**
* Verify a SyncedFile can be decrypted with given password
* Does not return the payload, just validates the password
*/
export const verifySyncedFile = async (
syncedFile: SyncedFile,
password: string
): Promise<boolean> => {
try {
await decryptPayload(syncedFile, password);
return true;
} catch {
return false;
}
};
// ============================================================================
// Master Key Management
// ============================================================================
/**
* Create a new master key configuration
*
* @param password - User's master password
* @returns Configuration to store (contains verification hash, not the key)
*/
export const createMasterKeyConfig = async (
password: string
): Promise<MasterKeyConfig> => {
const salt = generateRandomBytes(SYNC_CONSTANTS.SALT_LENGTH);
const key = await deriveKey(password, salt);
const verificationHash = await createVerificationHash(key);
return {
verificationHash,
salt: arrayBufferToBase64(salt),
kdf: 'PBKDF2',
kdfIterations: SYNC_CONSTANTS.PBKDF2_ITERATIONS,
createdAt: Date.now(),
};
};
/**
* Unlock the master key and return it for use
*
* @param password - User's master password
* @param config - Stored master key configuration
* @returns Unlocked key state (keep in memory only!)
*/
export const unlockMasterKey = async (
password: string,
config: MasterKeyConfig
): Promise<UnlockedMasterKey | null> => {
const isValid = await verifyPassword(password, config);
if (!isValid) return null;
const salt = base64ToUint8Array(config.salt);
const derivedKey = await deriveKey(
password,
salt,
config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
return {
derivedKey,
salt,
unlockedAt: Date.now(),
};
};
/**
* Change master password
* Requires re-encrypting all synced data with new password
*
* @param oldPassword - Current master password
* @param newPassword - New master password
* @param config - Current master key configuration
* @returns New configuration, or null if old password is wrong
*/
export const changeMasterPassword = async (
oldPassword: string,
newPassword: string,
config: MasterKeyConfig
): Promise<MasterKeyConfig | null> => {
// Verify old password first
const isValid = await verifyPassword(oldPassword, config);
if (!isValid) return null;
// Create new configuration with new password
return createMasterKeyConfig(newPassword);
};
// ============================================================================
// Export Service Class
// ============================================================================
/**
* EncryptionService class - stateless encryption operations
*/
export class EncryptionService {
static deriveKey = deriveKey;
static encrypt = encrypt;
static decrypt = decrypt;
static encryptPayload = encryptPayload;
static decryptPayload = decryptPayload;
static createMasterKeyConfig = createMasterKeyConfig;
static unlockMasterKey = unlockMasterKey;
static changeMasterPassword = changeMasterPassword;
static verifyPassword = verifyPassword;
static createVerificationHash = createVerificationHash;
static generateRandomBytes = generateRandomBytes;
static arrayBufferToBase64 = arrayBufferToBase64;
static base64ToUint8Array = base64ToUint8Array;
}
export default EncryptionService;