sojorn/sojorn_app/lib/services/capsule_escrow_service.dart
2026-02-15 00:33:24 -06:00

247 lines
9.1 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'capsule_crypto.dart';
/// Escrow Recovery Service — Encrypts the user's private key with their
/// PIN/password and stores the encrypted blob on the server.
///
/// ## Security Model
/// 1. User's X25519 private key lives in FlutterSecureStorage (local only)
/// 2. On backup: deriveKey(PIN) → encrypt(privateKey) → upload encrypted blob
/// 3. On restore: download blob → deriveKey(PIN) → decrypt → import key pair
/// 4. Server stores ONLY the encrypted blob — it cannot derive the PIN or key
///
/// ## Recovery Flow
/// ```
/// User loses device →
/// Login on new device →
/// Enter PIN →
/// Download encrypted backup →
/// PBKDF2(PIN, salt) → AES key →
/// Decrypt → privateKey restored →
/// All capsule keys can be unboxed again
/// ```
class CapsuleEscrowService {
static const _storage = FlutterSecureStorage();
final String apiBaseUrl;
final Future<String?> Function() getAuthToken;
CapsuleEscrowService({
required this.apiBaseUrl,
required this.getAuthToken,
});
// ═══════════════════════════════════════════════════════════════════════
// BACKUP: Encrypt private key with PIN and upload
// ═══════════════════════════════════════════════════════════════════════
/// Create an encrypted backup of the user's private key.
///
/// [pin] — User's chosen PIN or password (minimum 6 characters).
///
/// Steps:
/// 1. Read the private key from secure storage
/// 2. Generate a random salt
/// 3. Derive an AES-256 key from PIN + salt via PBKDF2
/// 4. Encrypt the private key bytes
/// 5. Upload the encrypted blob + salt + public key to the server
Future<EscrowBackupResult> createBackup(String pin) async {
if (pin.length < 6) {
throw CapsuleCryptoException('PIN must be at least 6 characters');
}
// 1. Read existing private key
final privateKeyB64 = await _storage.read(key: 'capsule_private_key');
final publicKeyB64 = await _storage.read(key: 'capsule_public_key');
if (privateKeyB64 == null || publicKeyB64 == null) {
throw CapsuleCryptoException('No key pair found — generate one first');
}
final privateKeyBytes = base64Decode(privateKeyB64);
// 2. Generate salt
final salt = CapsuleCrypto.generateSalt();
// 3. Derive encryption key from PIN
final derivedKey = await CapsuleCrypto.deriveKeyFromPassword(
pin,
salt: salt,
);
// 4. Encrypt the private key
final sealed = await CapsuleCrypto.encryptWithDerivedKey(
Uint8List.fromList(privateKeyBytes),
derivedKey,
);
// 5. Build the escrow backup
final backup = EscrowBackup(
salt: base64Encode(salt),
iv: sealed.iv,
payload: sealed.payload,
publicKey: publicKeyB64,
);
// 6. Upload to server
await _uploadBackup(backup);
return EscrowBackupResult(
backup: backup,
uploaded: true,
);
}
/// Upload the encrypted backup blob to the server.
/// The server stores it in `capsule_key_backups` scoped to the authenticated user.
Future<void> _uploadBackup(EscrowBackup backup) async {
final token = await getAuthToken();
if (token == null) throw CapsuleCryptoException('Not authenticated');
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/capsule/escrow/backup'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: backup.serialize(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw CapsuleCryptoException(
'Failed to upload backup: ${response.statusCode}',
);
}
}
// ═══════════════════════════════════════════════════════════════════════
// RESTORE: Download backup and decrypt with PIN
// ═══════════════════════════════════════════════════════════════════════
/// Restore the user's private key from the server-stored encrypted backup.
///
/// [pin] — The same PIN/password used during backup creation.
///
/// Steps:
/// 1. Download the encrypted backup blob from the server
/// 2. Derive the same AES key from PIN + stored salt
/// 3. Decrypt the private key bytes
/// 4. Store the recovered key pair in secure storage
Future<void> restoreFromBackup(String pin) async {
if (pin.length < 6) {
throw CapsuleCryptoException('PIN must be at least 6 characters');
}
// 1. Download backup
final backup = await _downloadBackup();
// 2. Derive key from PIN + salt
final salt = base64Decode(backup.salt);
final derivedKey = await CapsuleCrypto.deriveKeyFromPassword(
pin,
salt: Uint8List.fromList(salt),
);
// 3. Decrypt the private key
final privateKeyBytes = await CapsuleCrypto.decryptWithDerivedKey(
backup.payload,
backup.iv,
derivedKey,
);
// 4. Validate: the private key should be 32 bytes (X25519)
if (privateKeyBytes.length != 32) {
throw CapsuleCryptoException(
'Recovered key has wrong length (${privateKeyBytes.length}) — wrong PIN?',
);
}
// 5. Store recovered key pair in secure storage
await _storage.write(
key: 'capsule_private_key',
value: base64Encode(privateKeyBytes),
);
await _storage.write(
key: 'capsule_public_key',
value: backup.publicKey,
);
}
/// Download the encrypted backup from the server.
Future<EscrowBackup> _downloadBackup() async {
final token = await getAuthToken();
if (token == null) throw CapsuleCryptoException('Not authenticated');
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/capsule/escrow/backup'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 404) {
throw CapsuleCryptoException('No backup found for this account');
}
if (response.statusCode != 200) {
throw CapsuleCryptoException(
'Failed to download backup: ${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final backupJson = json['backup'] as Map<String, dynamic>?;
if (backupJson == null) {
throw CapsuleCryptoException('Invalid backup response');
}
return EscrowBackup.fromJson(backupJson);
}
// ═══════════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════════
/// Check if a backup exists on the server for the current user.
Future<bool> hasBackup() async {
final token = await getAuthToken();
if (token == null) return false;
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/capsule/escrow/status'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['has_backup'] as bool? ?? false;
}
return false;
}
/// Delete the server-stored backup (e.g. when user wants to re-create it).
Future<void> deleteBackup() async {
final token = await getAuthToken();
if (token == null) throw CapsuleCryptoException('Not authenticated');
await http.delete(
Uri.parse('$apiBaseUrl/api/v1/capsule/escrow/backup'),
headers: {'Authorization': 'Bearer $token'},
);
}
/// Check if a local key pair exists (i.e. user has generated keys).
static Future<bool> hasLocalKeyPair() async {
final pk = await _storage.read(key: 'capsule_private_key');
return pk != null;
}
}
// ═════════════════════════════════════════════════════════════════════════════
// DATA CLASSES
// ═════════════════════════════════════════════════════════════════════════════
class EscrowBackupResult {
final EscrowBackup backup;
final bool uploaded;
const EscrowBackupResult({required this.backup, required this.uploaded});
}