[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
450
infrastructure/services/EncryptionService.ts
Normal file
450
infrastructure/services/EncryptionService.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user