diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf5f8a7..79067bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: with: channel: stable - uses: dtolnay/rust-toolchain@stable - - run: cargo install flutter_rust_bridge_codegen + - run: cargo install flutter_rust_bridge_codegen@2.11.1 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs @@ -62,7 +62,7 @@ jobs: - uses: nttld/setup-ndk@v1 with: ndk-version: r27c - - run: cargo install flutter_rust_bridge_codegen + - run: cargo install flutter_rust_bridge_codegen@2.11.1 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs @@ -80,7 +80,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-ios,aarch64-apple-ios-sim - - run: cargo install flutter_rust_bridge_codegen + - run: cargo install flutter_rust_bridge_codegen@2.11.1 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs @@ -97,7 +97,7 @@ jobs: channel: stable - uses: dtolnay/rust-toolchain@stable - run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev - - run: cargo install flutter_rust_bridge_codegen + - run: cargo install flutter_rust_bridge_codegen@2.11.1 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e570f..f8049cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.3.4](https://github.com/MicroClub-USTHB/M-Security/releases/tag/v0.3.4) - 2026-04-05 + +### Added + +- Master key rotation via `vault_rotate_key()` — re-encrypts all vault data under a new key using atomic copy-to-new-vault + rename strategy. Crash recovery: stale `.rotating` file cleaned on `vault_open()`. +- Vault export via `vault_export()` — produces a self-contained `.mvex` encrypted archive with BLAKE3 integrity trailer, ephemeral export key AEAD-wrapped with caller's wrapping key, and per-segment re-encryption. +- Vault import via `vault_import()` — reads `.mvex` archive, creates new vault re-encrypted under a local master key. Validates header, unwraps export key, verifies per-segment BLAKE3 checksums and trailer integrity. +- `ImportFailed`, `ExportFailed`, and `KeyRotationFailed` error variants in `CryptoError`. +- Dart `VaultService.rotateKey()`, `VaultService.export()`, and `VaultService.importVault()` static methods. +- 7 Dart integration tests for key management (rotation roundtrip, old key rejection, export-import roundtrip, wrong wrapping key, 1MB+ segment, multiple rotations, rotate-then-export-import). +- 26 Rust tests for key management (10 rotation + 7 export + 9 import). +- Example app Key Management section with Rotate Key, Export, and Import buttons. +- CI workflow pinned `flutter_rust_bridge_codegen` to v2.11.1 to match runtime dependency. + +### Security + +- Old sub-keys zeroized immediately after rotation via `ZeroizeOnDrop`. +- Export wrapping uses AAD `b"msec-export-key-wrap"` for domain separation. +- Archive format authenticated: per-segment AAD (segment name) + BLAKE3 trailer covering all preceding bytes. +- `vault_write` plaintext wrapped in `Zeroizing>` — guarantees zeroization on all exit paths including `encrypt_segment` failure. +- Import hardened: `u64` to `usize` safe cast via `try_from` (32-bit overflow protection), OOM guard in `u64` arithmetic with `saturating_add`, `.lock` file cleanup in error paths, segment name validation (1-255 bytes), `segment_count` sanity bound (100K), unknown compression byte rejection. +- Atomic rename before lock release in rotation (closes race window). + +### Fixed + +- `unwrap()` in `archive.rs` replaced with `map_err()` for clippy compliance. +- `from_bytes` errors remapped to `ImportFailed` at import call sites. +- Example app lint: replaced `src/` imports with public `m_security.dart` re-exports. + ## [v0.3.3](https://github.com/MicroClub-USTHB/M-Security/releases/tag/v0.3.3) - 2026-03-28 ### Added diff --git a/README.md b/README.md index dab3726..71df783 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Built and maintained by the **Dev Department** of [MicroClub](https://github.com | **Password Hashing** | Argon2id | PHC winner, Mobile and Desktop presets | | **Key Derivation** | HKDF-SHA256 | RFC 5869, extract-then-expand with domain separation | | **Encrypted VFS (EVFS)** | `.vault` container | Named segments, WAL recovery, shadow index, secure deletion | +| **Key Management** | Rotation, export/import | Atomic re-encryption, `.mvex` portable archives | | **Zero-Copy I/O** | mmap + DCO codec | Memory-mapped vault reads, zero-copy Rust-to-Dart transfers | **Security by design:** @@ -48,7 +49,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - m_security: ^0.3.3 + m_security: ^0.3.4 ``` Then run: @@ -224,6 +225,31 @@ await VaultService.delete(handle: handle, name: 'secret.txt'); await VaultService.close(handle: handle); ``` +#### Key Management + +```dart +// Rotate master key (re-encrypts all segments atomically) +final newHandle = await VaultService.rotateKey(handle: handle, newKey: newKey); +// Old handle is invalidated; use newHandle from here + +// Export vault to portable encrypted archive +await VaultService.export( + handle: handle, + wrappingKey: wrappingKey, + exportPath: '/path/to/backup.mvex', +); + +// Import vault from archive (creates new vault with fresh key) +final imported = await VaultService.importVault( + archivePath: '/path/to/backup.mvex', + wrappingKey: wrappingKey, + destPath: '/path/to/restored.vault', + newMasterKey: localKey, + algorithm: 'aes-256-gcm', + capacityBytes: 10 * 1024 * 1024, +); +``` + #### Vault Maintenance ```dart @@ -366,7 +392,7 @@ flutter test integration_test/ | **EVFS v2: Defrag & resize** | Online defragmentation, vault resizing, health diagnostics | v0.3.1 | | **EVFS v2: Streaming I/O** | Constant-memory streaming reads/writes, per-chunk AEAD, progress callbacks | v0.3.2 | | **Zero-copy FFI optimization** | mmap vault reads, DCO codec, release profile hardening, symbol stripping | v0.3.3 | -| **EVFS v2: Key rotation** | Re-encrypt vault with new master key | Planned | +| **EVFS v2: Key management** | Key rotation, vault export/import (`.mvex` archives), Dart wrappers | v0.3.4 | | **Stealth storage** | Ephemeral secrets in Rust-managed memory with derived-path obfuscation | Planned | | **Hardware key wrap** | Master key in Secure Enclave (iOS) / KeyStore (Android) with biometric unlock | Planned | diff --git a/example/lib/main.dart b/example/lib/main.dart index 7d717ce..d2966c5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,11 +3,8 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:m_security/m_security.dart' as msec; import 'package:m_security/m_security.dart'; -import 'package:m_security/src/rust/api/encryption.dart' as rust_enc; -import 'package:m_security/src/rust/api/hashing.dart' as hashing; -import 'package:m_security/src/rust/api/compression.dart'; -import 'package:m_security/src/rust/api/evfs/types.dart' as rust_evfs_types; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -21,7 +18,7 @@ class ExampleApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'M-Security v0.3.3', + title: 'M-Security v0.3.4', theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true), home: const DemoHome(), ); @@ -41,7 +38,7 @@ class _DemoHomeState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('M-Security v0.3.3')), + appBar: AppBar(title: const Text('M-Security v0.3.4')), body: IndexedStack( index: _tab, children: const [ @@ -113,7 +110,7 @@ class _HashingTabState extends State<_HashingTab> { onPressed: _loading ? null : () => _run('BLAKE3', () async { - final h = await hashing.blake3Hash( + final h = await msec.blake3Hash( data: utf8.encode(_input.text), ); return _hex(h); @@ -124,7 +121,7 @@ class _HashingTabState extends State<_HashingTab> { onPressed: _loading ? null : () => _run('SHA-3', () async { - final h = await hashing.sha3Hash( + final h = await msec.sha3Hash( data: utf8.encode(_input.text), ); return _hex(h); @@ -186,7 +183,7 @@ class _EncryptionTabState extends State<_EncryptionTab> { final _input = TextEditingController(text: 'Secret message'); String _algo = 'AES-256-GCM'; Uint8List? _encrypted; - rust_enc.CipherHandle? _cipher; + msec.CipherHandle? _cipher; String _encHex = ''; String _decrypted = ''; bool _loading = false; @@ -199,12 +196,12 @@ class _EncryptionTabState extends State<_EncryptionTab> { }); try { final key = _algo == 'AES-256-GCM' - ? await rust_enc.generateAes256GcmKey() - : await rust_enc.generateChacha20Poly1305Key(); + ? await msec.generateAes256GcmKey() + : await msec.generateChacha20Poly1305Key(); _cipher = _algo == 'AES-256-GCM' - ? await rust_enc.createAes256Gcm(key: key) - : await rust_enc.createChacha20Poly1305(key: key); - _encrypted = await rust_enc.encrypt( + ? await msec.createAes256Gcm(key: key) + : await msec.createChacha20Poly1305(key: key); + _encrypted = await msec.encrypt( cipher: _cipher!, plaintext: Uint8List.fromList(utf8.encode(_input.text)), aad: Uint8List(0), @@ -220,7 +217,7 @@ class _EncryptionTabState extends State<_EncryptionTab> { if (_encrypted == null || _cipher == null) return; setState(() => _loading = true); try { - final plain = await rust_enc.decrypt( + final plain = await msec.decrypt( cipher: _cipher!, ciphertext: _encrypted!, aad: Uint8List(0), @@ -419,11 +416,11 @@ class _StreamingTabState extends State<_StreamingTab> { // Create cipher final key = _algo == 'AES-256-GCM' - ? await rust_enc.generateAes256GcmKey() - : await rust_enc.generateChacha20Poly1305Key(); + ? await msec.generateAes256GcmKey() + : await msec.generateChacha20Poly1305Key(); final cipher = _algo == 'AES-256-GCM' - ? await rust_enc.createAes256Gcm(key: key) - : await rust_enc.createChacha20Poly1305(key: key); + ? await msec.createAes256Gcm(key: key) + : await msec.createChacha20Poly1305(key: key); // Encrypt setState(() => _status = 'Encrypting ${_sizeKb.text}KB...'); @@ -453,8 +450,8 @@ class _StreamingTabState extends State<_StreamingTab> { // Decrypt final cipher2 = _algo == 'AES-256-GCM' - ? await rust_enc.createAes256Gcm(key: key) - : await rust_enc.createChacha20Poly1305(key: key); + ? await msec.createAes256Gcm(key: key) + : await msec.createChacha20Poly1305(key: key); if (_compAlgo == 'None') { await StreamingService.decryptFile( @@ -492,7 +489,7 @@ class _StreamingTabState extends State<_StreamingTab> { Future _testStreamHash() async { setState(() { _loading = true; - _status = 'Creating test file for hashing...'; + _status = 'Creating test file for msec...'; _progress = 0; }); try { @@ -507,11 +504,11 @@ class _StreamingTabState extends State<_StreamingTab> { await File(filePath).writeAsBytes(data); // One-shot hash for comparison - final oneshotHash = await hashing.blake3Hash(data: data); + final oneshotHash = await msec.blake3Hash(data: data); // Streaming hash setState(() => _status = 'Streaming BLAKE3 hash...'); - final hasher = await hashing.createBlake3(); + final hasher = await msec.createBlake3(); final streamHash = await StreamingService.hashFile( filePath: filePath, hasher: hasher, @@ -621,8 +618,10 @@ class _VaultTabState extends State<_VaultTab> { bool _vaultOpen = false; String _compAlgo = 'Zstd'; final _resizeMb = TextEditingController(text: '10'); + String _keyMgmtInfo = ''; + String? _exportPath; - rust_evfs_types.VaultHandle? _handle; + msec.VaultHandle? _handle; Uint8List? _key; String? _vaultPath; @@ -631,7 +630,7 @@ class _VaultTabState extends State<_VaultTab> { try { final dir = await Directory.systemTemp.createTemp('demo_vault'); _vaultPath = '${dir.path}/demo.vault'; - _key = await rust_enc.generateAes256GcmKey(); + _key = await msec.generateAes256GcmKey(); final sizeMb = int.tryParse(_vaultSizeMb.text) ?? 5; _handle = await VaultService.create( path: _vaultPath!, @@ -893,6 +892,89 @@ class _VaultTabState extends State<_VaultTab> { setState(() => _loading = false); } + Future _rotateKey() async { + if (!_vaultOpen || _handle == null) return; + setState(() => _loading = true); + try { + final newKey = await msec.generateAes256GcmKey(); + _handle = await VaultService.rotateKey(handle: _handle!, newKey: newKey); + _key = newKey; + _keyMgmtInfo = 'Key rotated — all segments re-encrypted under new key'; + _status = 'Key rotation complete'; + await _refreshList(); + await _refreshCapacity(); + } catch (e) { + _keyMgmtInfo = 'Rotation error: $e'; + } + setState(() => _loading = false); + } + + Future _exportVault() async { + if (!_vaultOpen || _handle == null) return; + setState(() => _loading = true); + try { + final dir = Directory(_vaultPath!).parent; + _exportPath = '${dir.path}/export.mvex'; + final wrappingKey = await msec.generateAes256GcmKey(); + await VaultService.export( + handle: _handle!, + wrappingKey: wrappingKey, + exportPath: _exportPath!, + ); + final size = await File(_exportPath!).length(); + _keyMgmtInfo = + 'Exported to ${_exportPath!.split('/').last} ' + '(${_fmtBytes(BigInt.from(size))})\n' + 'Wrapping key: ${_hex(wrappingKey).substring(0, 16)}...'; + _exportWrappingKey = wrappingKey; + _status = 'Vault exported'; + } catch (e) { + _keyMgmtInfo = 'Export error: $e'; + } + setState(() => _loading = false); + } + + Uint8List? _exportWrappingKey; + + Future _importVault() async { + if (_exportPath == null || _exportWrappingKey == null) { + setState(() => _keyMgmtInfo = 'Export a vault first'); + return; + } + setState(() => _loading = true); + try { + if (_vaultOpen && _handle != null) { + await VaultService.close(handle: _handle!); + } + + final dir = Directory(_vaultPath!).parent; + final importPath = '${dir.path}/imported.vault'; + final importKey = await msec.generateAes256GcmKey(); + + _handle = await VaultService.importVault( + archivePath: _exportPath!, + wrappingKey: _exportWrappingKey!, + destPath: importPath, + newMasterKey: importKey, + algorithm: 'aes-256-gcm', + capacityBytes: (int.tryParse(_vaultSizeMb.text) ?? 5) * 1024 * 1024, + ); + _key = importKey; + _vaultPath = importPath; + _vaultOpen = true; + + await _refreshList(); + await _refreshCapacity(); + _keyMgmtInfo = + 'Imported ${_segments.length} segments into new vault\n' + 'Path: ${importPath.split('/').last}'; + _status = 'Vault imported'; + } catch (e) { + _keyMgmtInfo = 'Import error: $e'; + } + setState(() => _loading = false); + } + @override Widget build(BuildContext context) { return ListView( @@ -1098,6 +1180,42 @@ class _VaultTabState extends State<_VaultTab> { const Divider(height: 24), + // Key Management + Text('Key Management', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + onPressed: _loading ? null : _rotateKey, + icon: const Icon(Icons.autorenew, size: 18), + label: const Text('Rotate Key'), + ), + ), + const SizedBox(width: 6), + Expanded( + child: FilledButton.tonalIcon( + onPressed: _loading ? null : _exportVault, + icon: const Icon(Icons.upload_file, size: 18), + label: const Text('Export'), + ), + ), + const SizedBox(width: 6), + Expanded( + child: FilledButton.tonalIcon( + onPressed: _loading || _exportPath == null + ? null + : _importVault, + icon: const Icon(Icons.download, size: 18), + label: const Text('Import'), + ), + ), + ], + ), + if (_keyMgmtInfo.isNotEmpty) _ResultCard('Key Mgmt', _keyMgmtInfo), + + const Divider(height: 24), + // Streaming segment I/O Text('Streaming I/O', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), diff --git a/example/pubspec.lock b/example/pubspec.lock index 817012b..563dfac 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -176,7 +176,7 @@ packages: path: ".." relative: true source: path - version: "0.3.3" + version: "0.3.4" matcher: dependency: transitive description: diff --git a/integration_test/vault_key_management_test.dart b/integration_test/vault_key_management_test.dart new file mode 100644 index 0000000..bd052ac --- /dev/null +++ b/integration_test/vault_key_management_test.dart @@ -0,0 +1,287 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:m_security/src/rust/api/encryption.dart'; +import 'package:m_security/src/rust/frb_generated.dart'; +import 'package:m_security/src/evfs/vault_service.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async => await RustLib.init()); + + group('Key Management', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('key_mgmt_test'); + }); + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('rotateKey roundtrip: write, rotate, read back', () async { + final path = '${tempDir.path}/rotate.vault'; + final key = await generateAes256GcmKey(); + final newKey = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final dataA = Uint8List.fromList(List.generate(500, (i) => i % 256)); + final dataB = Uint8List.fromList(List.generate(1000, (i) => (i * 7) % 256)); + await VaultService.write(handle: handle, name: 'a.bin', data: dataA); + await VaultService.write(handle: handle, name: 'b.bin', data: dataB); + + // Rotate + handle = await VaultService.rotateKey(handle: handle, newKey: newKey); + + // All segments readable with new handle + expect(await VaultService.read(handle: handle, name: 'a.bin'), dataA); + expect(await VaultService.read(handle: handle, name: 'b.bin'), dataB); + + await VaultService.close(handle: handle); + }); + + test('old key rejected after rotation', () async { + final path = '${tempDir.path}/oldkey.vault'; + final key = await generateAes256GcmKey(); + final newKey = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + + await VaultService.write( + handle: handle, + name: 'secret.bin', + data: Uint8List.fromList([1, 2, 3]), + ); + + handle = await VaultService.rotateKey(handle: handle, newKey: newKey); + await VaultService.close(handle: handle); + + // Old key must fail + expect( + () async => await VaultService.open(path: path, key: key), + throwsA(isA()), + ); + + // New key works + final reopened = await VaultService.open(path: path, key: newKey); + expect( + await VaultService.read(handle: reopened, name: 'secret.bin'), + Uint8List.fromList([1, 2, 3]), + ); + await VaultService.close(handle: reopened); + }); + + test('export-import roundtrip: data matches byte-for-byte', () async { + final vaultPath = '${tempDir.path}/source.vault'; + final archivePath = '${tempDir.path}/export.mvex'; + final importPath = '${tempDir.path}/imported.vault'; + final key = await generateAes256GcmKey(); + final wrappingKey = await generateAes256GcmKey(); + final importKey = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: vaultPath, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data1 = Uint8List.fromList(List.generate(800, (i) => i % 256)); + final data2 = Uint8List.fromList(List.generate(1200, (i) => (i * 3) % 256)); + await VaultService.write(handle: handle, name: 'file1.dat', data: data1); + await VaultService.write(handle: handle, name: 'file2.dat', data: data2); + + // Export + await VaultService.export( + handle: handle, + wrappingKey: wrappingKey, + exportPath: archivePath, + ); + await VaultService.close(handle: handle); + + expect(File(archivePath).existsSync(), isTrue); + + // Import into new vault + final imported = await VaultService.importVault( + archivePath: archivePath, + wrappingKey: wrappingKey, + destPath: importPath, + newMasterKey: importKey, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + expect(await VaultService.read(handle: imported, name: 'file1.dat'), data1); + expect(await VaultService.read(handle: imported, name: 'file2.dat'), data2); + + await VaultService.close(handle: imported); + }); + + test('import with wrong wrapping key throws', () async { + final vaultPath = '${tempDir.path}/wk.vault'; + final archivePath = '${tempDir.path}/wk.mvex'; + final key = await generateAes256GcmKey(); + final wrappingKey = await generateAes256GcmKey(); + final wrongKey = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: vaultPath, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ); + await VaultService.write( + handle: handle, + name: 'x.bin', + data: Uint8List.fromList([42]), + ); + await VaultService.export( + handle: handle, + wrappingKey: wrappingKey, + exportPath: archivePath, + ); + await VaultService.close(handle: handle); + + expect( + () async => await VaultService.importVault( + archivePath: archivePath, + wrappingKey: wrongKey, + destPath: '${tempDir.path}/bad.vault', + newMasterKey: await generateAes256GcmKey(), + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ), + throwsA(isA()), + ); + }); + + test('large segment (1MB+) survives export-import', () async { + final vaultPath = '${tempDir.path}/big.vault'; + final archivePath = '${tempDir.path}/big.mvex'; + final importPath = '${tempDir.path}/big_imported.vault'; + final key = await generateAes256GcmKey(); + final wrappingKey = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: vaultPath, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, + ); + + final bigData = Uint8List.fromList( + List.generate(1024 * 1024 + 37, (i) => (i * 13) % 256), + ); + await VaultService.write(handle: handle, name: 'big.bin', data: bigData); + + await VaultService.export( + handle: handle, + wrappingKey: wrappingKey, + exportPath: archivePath, + ); + await VaultService.close(handle: handle); + + final imported = await VaultService.importVault( + archivePath: archivePath, + wrappingKey: wrappingKey, + destPath: importPath, + newMasterKey: await generateAes256GcmKey(), + algorithm: 'aes-256-gcm', + capacityBytes: 5 * 1024 * 1024, + ); + + expect(await VaultService.read(handle: imported, name: 'big.bin'), bigData); + await VaultService.close(handle: imported); + }); + + test('multiple sequential rotations', () async { + final path = '${tempDir.path}/multi_rot.vault'; + final key1 = await generateAes256GcmKey(); + final key2 = await generateAes256GcmKey(); + final key3 = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: path, + key: key1, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList([10, 20, 30, 40, 50]); + await VaultService.write(handle: handle, name: 'data.bin', data: data); + + // Rotate twice + handle = await VaultService.rotateKey(handle: handle, newKey: key2); + expect(await VaultService.read(handle: handle, name: 'data.bin'), data); + + handle = await VaultService.rotateKey(handle: handle, newKey: key3); + expect(await VaultService.read(handle: handle, name: 'data.bin'), data); + + await VaultService.close(handle: handle); + + // Only key3 works + final reopened = await VaultService.open(path: path, key: key3); + expect(await VaultService.read(handle: reopened, name: 'data.bin'), data); + await VaultService.close(handle: reopened); + }); + + test('rotate then export-import the rotated vault', () async { + final vaultPath = '${tempDir.path}/rot_exp.vault'; + final archivePath = '${tempDir.path}/rot_exp.mvex'; + final importPath = '${tempDir.path}/rot_exp_imported.vault'; + final key = await generateAes256GcmKey(); + final rotatedKey = await generateAes256GcmKey(); + final wrappingKey = await generateAes256GcmKey(); + final importKey = await generateAes256GcmKey(); + + var handle = await VaultService.create( + path: vaultPath, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + final data = Uint8List.fromList(List.generate(256, (i) => i)); + await VaultService.write(handle: handle, name: 'payload.bin', data: data); + + // Rotate first + handle = await VaultService.rotateKey(handle: handle, newKey: rotatedKey); + + // Then export + await VaultService.export( + handle: handle, + wrappingKey: wrappingKey, + exportPath: archivePath, + ); + await VaultService.close(handle: handle); + + // Import + final imported = await VaultService.importVault( + archivePath: archivePath, + wrappingKey: wrappingKey, + destPath: importPath, + newMasterKey: importKey, + algorithm: 'aes-256-gcm', + capacityBytes: 2 * 1024 * 1024, + ); + + expect( + await VaultService.read(handle: imported, name: 'payload.bin'), + data, + ); + await VaultService.close(handle: imported); + }); + }); +} diff --git a/ios/m_security.podspec b/ios/m_security.podspec index db4060f..3810b1f 100644 --- a/ios/m_security.podspec +++ b/ios/m_security.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'm_security' - s.version = '0.3.3' + s.version = '0.3.4' s.summary = 'A high-performance cryptographic SDK for Flutter powered by native Rust via FFI.' s.description = <<-DESC A high-performance cryptographic SDK for Flutter powered by native Rust via FFI. diff --git a/lib/src/evfs/vault_service.dart b/lib/src/evfs/vault_service.dart index c8ef7ab..c5b92a0 100644 --- a/lib/src/evfs/vault_service.dart +++ b/lib/src/evfs/vault_service.dart @@ -265,6 +265,54 @@ class VaultService { ); } + /// Rotate the vault's master key (re-encrypts all data). + /// + /// Returns a new [VaultHandle] — the old handle is invalidated. + /// If interrupted, open the vault with the old key to recover. + static Future rotateKey({ + required rust_types.VaultHandle handle, + required Uint8List newKey, + }) { + return rust_evfs.vaultRotateKey(handle: handle, newKey: newKey); + } + + /// Export the vault to a portable encrypted archive (.mvex). + /// + /// The archive is encrypted with a random ephemeral key wrapped by + /// [wrappingKey]. Share the wrapping key out-of-band for import. + static Future export({ + required rust_types.VaultHandle handle, + required Uint8List wrappingKey, + required String exportPath, + }) { + return rust_evfs.vaultExport( + handle: handle, + wrappingKey: wrappingKey, + exportPath: exportPath, + ); + } + + /// Import a vault from an encrypted archive (.mvex). + /// + /// Creates a new vault at [destPath] re-encrypted under [newMasterKey]. + static Future importVault({ + required String archivePath, + required Uint8List wrappingKey, + required String destPath, + required Uint8List newMasterKey, + required String algorithm, + required int capacityBytes, + }) { + return rust_evfs.vaultImport( + archivePath: archivePath, + wrappingKey: wrappingKey, + destPath: destPath, + newMasterKey: newMasterKey, + algorithm: algorithm, + capacityBytes: BigInt.from(capacityBytes), + ); + } + /// Close the vault (release lock, zeroize keys). static Future close({required rust_types.VaultHandle handle}) { return rust_evfs.vaultClose(handle: handle); diff --git a/lib/src/rust/api/evfs.dart b/lib/src/rust/api/evfs.dart index 224065f..9bcdd41 100644 --- a/lib/src/rust/api/evfs.dart +++ b/lib/src/rust/api/evfs.dart @@ -9,7 +9,7 @@ import 'compression.dart'; import 'evfs/types.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `chunk_abs_offset`, `vault_resize_grow_impl`, `vault_resize_shrink_impl`, `write_encrypted_chunk` +// These functions are ignored because they are not marked as `pub`: `chunk_abs_offset`, `vault_export_write`, `vault_resize_grow_impl`, `vault_resize_shrink_impl`, `write_encrypted_chunk` // These functions have error during generation (see debug logs or enable `stop_on_error: true` for more details): `vault_write_stream` /// Create a new vault file at `path` with the given capacity. @@ -123,6 +123,49 @@ Future vaultHealth({required VaultHandle handle}) => Future vaultDefragment({required VaultHandle handle}) => RustLib.instance.api.crateApiEvfsVaultDefragment(handle: handle); +/// Consumes old handle (keys invalidated after rename). Returns new handle with new keys. +Future vaultRotateKey({ + required VaultHandle handle, + required List newKey, +}) => RustLib.instance.api.crateApiEvfsVaultRotateKey( + handle: handle, + newKey: newKey, +); + +/// Export all vault segments into a self-contained `.mvex` encrypted archive. +/// +/// Each segment is decrypted from the vault, then re-encrypted under an +/// ephemeral export key with a random per-segment nonce. The export key is +/// AEAD-wrapped with the caller's `wrapping_key`. +/// +/// The vault is not modified by this operation. +Future vaultExport({ + required VaultHandle handle, + required List wrappingKey, + required String exportPath, +}) => RustLib.instance.api.crateApiEvfsVaultExport( + handle: handle, + wrappingKey: wrappingKey, + exportPath: exportPath, +); + /// Close the vault — checkpoint WAL, release lock, zeroize keys on drop. Future vaultClose({required VaultHandle handle}) => RustLib.instance.api.crateApiEvfsVaultClose(handle: handle); + +/// Unwraps export key, creates new vault at dest_path, writes all segments under new_master_key. +Future vaultImport({ + required String archivePath, + required List wrappingKey, + required String destPath, + required List newMasterKey, + required String algorithm, + required BigInt capacityBytes, +}) => RustLib.instance.api.crateApiEvfsVaultImport( + archivePath: archivePath, + wrappingKey: wrappingKey, + destPath: destPath, + newMasterKey: newMasterKey, + algorithm: algorithm, + capacityBytes: capacityBytes, +); diff --git a/lib/src/rust/core/error.dart b/lib/src/rust/core/error.dart index da13bf9..56f448f 100644 --- a/lib/src/rust/core/error.dart +++ b/lib/src/rust/core/error.dart @@ -39,4 +39,10 @@ sealed class CryptoError with _$CryptoError implements FrbException { CryptoError_SegmentNotFound; const factory CryptoError.vaultCorrupted(String field0) = CryptoError_VaultCorrupted; + const factory CryptoError.keyRotationFailed(String field0) = + CryptoError_KeyRotationFailed; + const factory CryptoError.exportFailed(String field0) = + CryptoError_ExportFailed; + const factory CryptoError.importFailed(String field0) = + CryptoError_ImportFailed; } diff --git a/lib/src/rust/core/error.freezed.dart b/lib/src/rust/core/error.freezed.dart index ed1129d..e28b9dc 100644 --- a/lib/src/rust/core/error.freezed.dart +++ b/lib/src/rust/core/error.freezed.dart @@ -55,7 +55,7 @@ extension CryptoErrorPatterns on CryptoError { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult Function( CryptoError_InvalidNonce value)? invalidNonce,TResult Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult Function( CryptoError_HashingFailed value)? hashingFailed,TResult Function( CryptoError_KdfFailed value)? kdfFailed,TResult Function( CryptoError_IoError value)? ioError,TResult Function( CryptoError_InvalidParameter value)? invalidParameter,TResult Function( CryptoError_CompressionFailed value)? compressionFailed,TResult Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult Function( CryptoError_VaultFull value)? vaultFull,TResult Function( CryptoError_VaultLocked value)? vaultLocked,TResult Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult Function( CryptoError_VaultCorrupted value)? vaultCorrupted,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult Function( CryptoError_InvalidNonce value)? invalidNonce,TResult Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult Function( CryptoError_HashingFailed value)? hashingFailed,TResult Function( CryptoError_KdfFailed value)? kdfFailed,TResult Function( CryptoError_IoError value)? ioError,TResult Function( CryptoError_InvalidParameter value)? invalidParameter,TResult Function( CryptoError_CompressionFailed value)? compressionFailed,TResult Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult Function( CryptoError_VaultFull value)? vaultFull,TResult Function( CryptoError_VaultLocked value)? vaultLocked,TResult Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult Function( CryptoError_VaultCorrupted value)? vaultCorrupted,TResult Function( CryptoError_KeyRotationFailed value)? keyRotationFailed,TResult Function( CryptoError_ExportFailed value)? exportFailed,TResult Function( CryptoError_ImportFailed value)? importFailed,required TResult orElse(),}){ final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: @@ -72,7 +72,10 @@ return authenticationFailed(_that);case CryptoError_VaultFull() when vaultFull ! return vaultFull(_that);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked(_that);case CryptoError_SegmentNotFound() when segmentNotFound != null: return segmentNotFound(_that);case CryptoError_VaultCorrupted() when vaultCorrupted != null: -return vaultCorrupted(_that);case _: +return vaultCorrupted(_that);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: +return keyRotationFailed(_that);case CryptoError_ExportFailed() when exportFailed != null: +return exportFailed(_that);case CryptoError_ImportFailed() when importFailed != null: +return importFailed(_that);case _: return orElse(); } @@ -90,7 +93,7 @@ return vaultCorrupted(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( CryptoError_InvalidKeyLength value) invalidKeyLength,required TResult Function( CryptoError_InvalidNonce value) invalidNonce,required TResult Function( CryptoError_EncryptionFailed value) encryptionFailed,required TResult Function( CryptoError_DecryptionFailed value) decryptionFailed,required TResult Function( CryptoError_HashingFailed value) hashingFailed,required TResult Function( CryptoError_KdfFailed value) kdfFailed,required TResult Function( CryptoError_IoError value) ioError,required TResult Function( CryptoError_InvalidParameter value) invalidParameter,required TResult Function( CryptoError_CompressionFailed value) compressionFailed,required TResult Function( CryptoError_AuthenticationFailed value) authenticationFailed,required TResult Function( CryptoError_VaultFull value) vaultFull,required TResult Function( CryptoError_VaultLocked value) vaultLocked,required TResult Function( CryptoError_SegmentNotFound value) segmentNotFound,required TResult Function( CryptoError_VaultCorrupted value) vaultCorrupted,}){ +@optionalTypeArgs TResult map({required TResult Function( CryptoError_InvalidKeyLength value) invalidKeyLength,required TResult Function( CryptoError_InvalidNonce value) invalidNonce,required TResult Function( CryptoError_EncryptionFailed value) encryptionFailed,required TResult Function( CryptoError_DecryptionFailed value) decryptionFailed,required TResult Function( CryptoError_HashingFailed value) hashingFailed,required TResult Function( CryptoError_KdfFailed value) kdfFailed,required TResult Function( CryptoError_IoError value) ioError,required TResult Function( CryptoError_InvalidParameter value) invalidParameter,required TResult Function( CryptoError_CompressionFailed value) compressionFailed,required TResult Function( CryptoError_AuthenticationFailed value) authenticationFailed,required TResult Function( CryptoError_VaultFull value) vaultFull,required TResult Function( CryptoError_VaultLocked value) vaultLocked,required TResult Function( CryptoError_SegmentNotFound value) segmentNotFound,required TResult Function( CryptoError_VaultCorrupted value) vaultCorrupted,required TResult Function( CryptoError_KeyRotationFailed value) keyRotationFailed,required TResult Function( CryptoError_ExportFailed value) exportFailed,required TResult Function( CryptoError_ImportFailed value) importFailed,}){ final _that = this; switch (_that) { case CryptoError_InvalidKeyLength(): @@ -107,7 +110,10 @@ return authenticationFailed(_that);case CryptoError_VaultFull(): return vaultFull(_that);case CryptoError_VaultLocked(): return vaultLocked(_that);case CryptoError_SegmentNotFound(): return segmentNotFound(_that);case CryptoError_VaultCorrupted(): -return vaultCorrupted(_that);} +return vaultCorrupted(_that);case CryptoError_KeyRotationFailed(): +return keyRotationFailed(_that);case CryptoError_ExportFailed(): +return exportFailed(_that);case CryptoError_ImportFailed(): +return importFailed(_that);} } /// A variant of `map` that fallback to returning `null`. /// @@ -121,7 +127,7 @@ return vaultCorrupted(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult? Function( CryptoError_InvalidNonce value)? invalidNonce,TResult? Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult? Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult? Function( CryptoError_HashingFailed value)? hashingFailed,TResult? Function( CryptoError_KdfFailed value)? kdfFailed,TResult? Function( CryptoError_IoError value)? ioError,TResult? Function( CryptoError_InvalidParameter value)? invalidParameter,TResult? Function( CryptoError_CompressionFailed value)? compressionFailed,TResult? Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult? Function( CryptoError_VaultFull value)? vaultFull,TResult? Function( CryptoError_VaultLocked value)? vaultLocked,TResult? Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult? Function( CryptoError_VaultCorrupted value)? vaultCorrupted,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( CryptoError_InvalidKeyLength value)? invalidKeyLength,TResult? Function( CryptoError_InvalidNonce value)? invalidNonce,TResult? Function( CryptoError_EncryptionFailed value)? encryptionFailed,TResult? Function( CryptoError_DecryptionFailed value)? decryptionFailed,TResult? Function( CryptoError_HashingFailed value)? hashingFailed,TResult? Function( CryptoError_KdfFailed value)? kdfFailed,TResult? Function( CryptoError_IoError value)? ioError,TResult? Function( CryptoError_InvalidParameter value)? invalidParameter,TResult? Function( CryptoError_CompressionFailed value)? compressionFailed,TResult? Function( CryptoError_AuthenticationFailed value)? authenticationFailed,TResult? Function( CryptoError_VaultFull value)? vaultFull,TResult? Function( CryptoError_VaultLocked value)? vaultLocked,TResult? Function( CryptoError_SegmentNotFound value)? segmentNotFound,TResult? Function( CryptoError_VaultCorrupted value)? vaultCorrupted,TResult? Function( CryptoError_KeyRotationFailed value)? keyRotationFailed,TResult? Function( CryptoError_ExportFailed value)? exportFailed,TResult? Function( CryptoError_ImportFailed value)? importFailed,}){ final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: @@ -138,7 +144,10 @@ return authenticationFailed(_that);case CryptoError_VaultFull() when vaultFull ! return vaultFull(_that);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked(_that);case CryptoError_SegmentNotFound() when segmentNotFound != null: return segmentNotFound(_that);case CryptoError_VaultCorrupted() when vaultCorrupted != null: -return vaultCorrupted(_that);case _: +return vaultCorrupted(_that);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: +return keyRotationFailed(_that);case CryptoError_ExportFailed() when exportFailed != null: +return exportFailed(_that);case CryptoError_ImportFailed() when importFailed != null: +return importFailed(_that);case _: return null; } @@ -155,7 +164,7 @@ return vaultCorrupted(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult Function()? invalidNonce,TResult Function( String field0)? encryptionFailed,TResult Function()? decryptionFailed,TResult Function( String field0)? hashingFailed,TResult Function( String field0)? kdfFailed,TResult Function( String field0)? ioError,TResult Function( String field0)? invalidParameter,TResult Function( String field0)? compressionFailed,TResult Function()? authenticationFailed,TResult Function( BigInt needed, BigInt available)? vaultFull,TResult Function()? vaultLocked,TResult Function( String field0)? segmentNotFound,TResult Function( String field0)? vaultCorrupted,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult Function()? invalidNonce,TResult Function( String field0)? encryptionFailed,TResult Function()? decryptionFailed,TResult Function( String field0)? hashingFailed,TResult Function( String field0)? kdfFailed,TResult Function( String field0)? ioError,TResult Function( String field0)? invalidParameter,TResult Function( String field0)? compressionFailed,TResult Function()? authenticationFailed,TResult Function( BigInt needed, BigInt available)? vaultFull,TResult Function()? vaultLocked,TResult Function( String field0)? segmentNotFound,TResult Function( String field0)? vaultCorrupted,TResult Function( String field0)? keyRotationFailed,TResult Function( String field0)? exportFailed,TResult Function( String field0)? importFailed,required TResult orElse(),}) {final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: return invalidKeyLength(_that.expected,_that.actual);case CryptoError_InvalidNonce() when invalidNonce != null: @@ -171,7 +180,10 @@ return authenticationFailed();case CryptoError_VaultFull() when vaultFull != nul return vaultFull(_that.needed,_that.available);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked();case CryptoError_SegmentNotFound() when segmentNotFound != null: return segmentNotFound(_that.field0);case CryptoError_VaultCorrupted() when vaultCorrupted != null: -return vaultCorrupted(_that.field0);case _: +return vaultCorrupted(_that.field0);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: +return keyRotationFailed(_that.field0);case CryptoError_ExportFailed() when exportFailed != null: +return exportFailed(_that.field0);case CryptoError_ImportFailed() when importFailed != null: +return importFailed(_that.field0);case _: return orElse(); } @@ -189,7 +201,7 @@ return vaultCorrupted(_that.field0);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( BigInt expected, BigInt actual) invalidKeyLength,required TResult Function() invalidNonce,required TResult Function( String field0) encryptionFailed,required TResult Function() decryptionFailed,required TResult Function( String field0) hashingFailed,required TResult Function( String field0) kdfFailed,required TResult Function( String field0) ioError,required TResult Function( String field0) invalidParameter,required TResult Function( String field0) compressionFailed,required TResult Function() authenticationFailed,required TResult Function( BigInt needed, BigInt available) vaultFull,required TResult Function() vaultLocked,required TResult Function( String field0) segmentNotFound,required TResult Function( String field0) vaultCorrupted,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( BigInt expected, BigInt actual) invalidKeyLength,required TResult Function() invalidNonce,required TResult Function( String field0) encryptionFailed,required TResult Function() decryptionFailed,required TResult Function( String field0) hashingFailed,required TResult Function( String field0) kdfFailed,required TResult Function( String field0) ioError,required TResult Function( String field0) invalidParameter,required TResult Function( String field0) compressionFailed,required TResult Function() authenticationFailed,required TResult Function( BigInt needed, BigInt available) vaultFull,required TResult Function() vaultLocked,required TResult Function( String field0) segmentNotFound,required TResult Function( String field0) vaultCorrupted,required TResult Function( String field0) keyRotationFailed,required TResult Function( String field0) exportFailed,required TResult Function( String field0) importFailed,}) {final _that = this; switch (_that) { case CryptoError_InvalidKeyLength(): return invalidKeyLength(_that.expected,_that.actual);case CryptoError_InvalidNonce(): @@ -205,7 +217,10 @@ return authenticationFailed();case CryptoError_VaultFull(): return vaultFull(_that.needed,_that.available);case CryptoError_VaultLocked(): return vaultLocked();case CryptoError_SegmentNotFound(): return segmentNotFound(_that.field0);case CryptoError_VaultCorrupted(): -return vaultCorrupted(_that.field0);} +return vaultCorrupted(_that.field0);case CryptoError_KeyRotationFailed(): +return keyRotationFailed(_that.field0);case CryptoError_ExportFailed(): +return exportFailed(_that.field0);case CryptoError_ImportFailed(): +return importFailed(_that.field0);} } /// A variant of `when` that fallback to returning `null` /// @@ -219,7 +234,7 @@ return vaultCorrupted(_that.field0);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult? Function()? invalidNonce,TResult? Function( String field0)? encryptionFailed,TResult? Function()? decryptionFailed,TResult? Function( String field0)? hashingFailed,TResult? Function( String field0)? kdfFailed,TResult? Function( String field0)? ioError,TResult? Function( String field0)? invalidParameter,TResult? Function( String field0)? compressionFailed,TResult? Function()? authenticationFailed,TResult? Function( BigInt needed, BigInt available)? vaultFull,TResult? Function()? vaultLocked,TResult? Function( String field0)? segmentNotFound,TResult? Function( String field0)? vaultCorrupted,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( BigInt expected, BigInt actual)? invalidKeyLength,TResult? Function()? invalidNonce,TResult? Function( String field0)? encryptionFailed,TResult? Function()? decryptionFailed,TResult? Function( String field0)? hashingFailed,TResult? Function( String field0)? kdfFailed,TResult? Function( String field0)? ioError,TResult? Function( String field0)? invalidParameter,TResult? Function( String field0)? compressionFailed,TResult? Function()? authenticationFailed,TResult? Function( BigInt needed, BigInt available)? vaultFull,TResult? Function()? vaultLocked,TResult? Function( String field0)? segmentNotFound,TResult? Function( String field0)? vaultCorrupted,TResult? Function( String field0)? keyRotationFailed,TResult? Function( String field0)? exportFailed,TResult? Function( String field0)? importFailed,}) {final _that = this; switch (_that) { case CryptoError_InvalidKeyLength() when invalidKeyLength != null: return invalidKeyLength(_that.expected,_that.actual);case CryptoError_InvalidNonce() when invalidNonce != null: @@ -235,7 +250,10 @@ return authenticationFailed();case CryptoError_VaultFull() when vaultFull != nul return vaultFull(_that.needed,_that.available);case CryptoError_VaultLocked() when vaultLocked != null: return vaultLocked();case CryptoError_SegmentNotFound() when segmentNotFound != null: return segmentNotFound(_that.field0);case CryptoError_VaultCorrupted() when vaultCorrupted != null: -return vaultCorrupted(_that.field0);case _: +return vaultCorrupted(_that.field0);case CryptoError_KeyRotationFailed() when keyRotationFailed != null: +return keyRotationFailed(_that.field0);case CryptoError_ExportFailed() when exportFailed != null: +return exportFailed(_that.field0);case CryptoError_ImportFailed() when importFailed != null: +return importFailed(_that.field0);case _: return null; } @@ -1033,6 +1051,204 @@ as String, } +} + +/// @nodoc + + +class CryptoError_KeyRotationFailed extends CryptoError { + const CryptoError_KeyRotationFailed(this.field0): super._(); + + + final String field0; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CryptoError_KeyRotationFailedCopyWith get copyWith => _$CryptoError_KeyRotationFailedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CryptoError_KeyRotationFailed&&(identical(other.field0, field0) || other.field0 == field0)); +} + + +@override +int get hashCode => Object.hash(runtimeType,field0); + +@override +String toString() { + return 'CryptoError.keyRotationFailed(field0: $field0)'; +} + + +} + +/// @nodoc +abstract mixin class $CryptoError_KeyRotationFailedCopyWith<$Res> implements $CryptoErrorCopyWith<$Res> { + factory $CryptoError_KeyRotationFailedCopyWith(CryptoError_KeyRotationFailed value, $Res Function(CryptoError_KeyRotationFailed) _then) = _$CryptoError_KeyRotationFailedCopyWithImpl; +@useResult +$Res call({ + String field0 +}); + + + + +} +/// @nodoc +class _$CryptoError_KeyRotationFailedCopyWithImpl<$Res> + implements $CryptoError_KeyRotationFailedCopyWith<$Res> { + _$CryptoError_KeyRotationFailedCopyWithImpl(this._self, this._then); + + final CryptoError_KeyRotationFailed _self; + final $Res Function(CryptoError_KeyRotationFailed) _then; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) { + return _then(CryptoError_KeyRotationFailed( +null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class CryptoError_ExportFailed extends CryptoError { + const CryptoError_ExportFailed(this.field0): super._(); + + + final String field0; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CryptoError_ExportFailedCopyWith get copyWith => _$CryptoError_ExportFailedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CryptoError_ExportFailed&&(identical(other.field0, field0) || other.field0 == field0)); +} + + +@override +int get hashCode => Object.hash(runtimeType,field0); + +@override +String toString() { + return 'CryptoError.exportFailed(field0: $field0)'; +} + + +} + +/// @nodoc +abstract mixin class $CryptoError_ExportFailedCopyWith<$Res> implements $CryptoErrorCopyWith<$Res> { + factory $CryptoError_ExportFailedCopyWith(CryptoError_ExportFailed value, $Res Function(CryptoError_ExportFailed) _then) = _$CryptoError_ExportFailedCopyWithImpl; +@useResult +$Res call({ + String field0 +}); + + + + +} +/// @nodoc +class _$CryptoError_ExportFailedCopyWithImpl<$Res> + implements $CryptoError_ExportFailedCopyWith<$Res> { + _$CryptoError_ExportFailedCopyWithImpl(this._self, this._then); + + final CryptoError_ExportFailed _self; + final $Res Function(CryptoError_ExportFailed) _then; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) { + return _then(CryptoError_ExportFailed( +null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class CryptoError_ImportFailed extends CryptoError { + const CryptoError_ImportFailed(this.field0): super._(); + + + final String field0; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CryptoError_ImportFailedCopyWith get copyWith => _$CryptoError_ImportFailedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CryptoError_ImportFailed&&(identical(other.field0, field0) || other.field0 == field0)); +} + + +@override +int get hashCode => Object.hash(runtimeType,field0); + +@override +String toString() { + return 'CryptoError.importFailed(field0: $field0)'; +} + + +} + +/// @nodoc +abstract mixin class $CryptoError_ImportFailedCopyWith<$Res> implements $CryptoErrorCopyWith<$Res> { + factory $CryptoError_ImportFailedCopyWith(CryptoError_ImportFailed value, $Res Function(CryptoError_ImportFailed) _then) = _$CryptoError_ImportFailedCopyWithImpl; +@useResult +$Res call({ + String field0 +}); + + + + +} +/// @nodoc +class _$CryptoError_ImportFailedCopyWithImpl<$Res> + implements $CryptoError_ImportFailedCopyWith<$Res> { + _$CryptoError_ImportFailedCopyWithImpl(this._self, this._then); + + final CryptoError_ImportFailed _self; + final $Res Function(CryptoError_ImportFailed) _then; + +/// Create a copy of CryptoError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) { + return _then(CryptoError_ImportFailed( +null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + } // dart format on diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index 143d2ff..3334316 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => 2084471439; + int get rustContentHash => 1455605677; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -254,10 +254,25 @@ abstract class RustLibApi extends BaseApi { required String name, }); + Future crateApiEvfsVaultExport({ + required VaultHandle handle, + required List wrappingKey, + required String exportPath, + }); + Future crateApiEvfsVaultHealth({ required VaultHandle handle, }); + Future crateApiEvfsVaultImport({ + required String archivePath, + required List wrappingKey, + required String destPath, + required List newMasterKey, + required String algorithm, + required BigInt capacityBytes, + }); + Future> crateApiEvfsVaultList({required VaultHandle handle}); Future crateApiEvfsVaultOpen({ @@ -283,6 +298,11 @@ abstract class RustLibApi extends BaseApi { required BigInt newCapacity, }); + Future crateApiEvfsVaultRotateKey({ + required VaultHandle handle, + required List newKey, + }); + Future crateApiEvfsVaultWrite({ required VaultHandle handle, required String name, @@ -1595,6 +1615,44 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["handle", "name"], ); + @override + Future crateApiEvfsVaultExport({ + required VaultHandle handle, + required List wrappingKey, + required String exportPath, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = + cst_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + handle, + ); + var arg1 = cst_encode_list_prim_u_8_loose(wrappingKey); + var arg2 = cst_encode_String(exportPath); + return wire.wire__crate__api__evfs__vault_export( + port_, + arg0, + arg1, + arg2, + ); + }, + codec: DcoCodec( + decodeSuccessData: dco_decode_unit, + decodeErrorData: dco_decode_crypto_error, + ), + constMeta: kCrateApiEvfsVaultExportConstMeta, + argValues: [handle, wrappingKey, exportPath], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiEvfsVaultExportConstMeta => const TaskConstMeta( + debugName: "vault_export", + argNames: ["handle", "wrappingKey", "exportPath"], + ); + @override Future crateApiEvfsVaultHealth({ required VaultHandle handle, @@ -1622,6 +1680,65 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiEvfsVaultHealthConstMeta => const TaskConstMeta(debugName: "vault_health", argNames: ["handle"]); + @override + Future crateApiEvfsVaultImport({ + required String archivePath, + required List wrappingKey, + required String destPath, + required List newMasterKey, + required String algorithm, + required BigInt capacityBytes, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = cst_encode_String(archivePath); + var arg1 = cst_encode_list_prim_u_8_loose(wrappingKey); + var arg2 = cst_encode_String(destPath); + var arg3 = cst_encode_list_prim_u_8_loose(newMasterKey); + var arg4 = cst_encode_String(algorithm); + var arg5 = cst_encode_u_64(capacityBytes); + return wire.wire__crate__api__evfs__vault_import( + port_, + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + ); + }, + codec: DcoCodec( + decodeSuccessData: + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle, + decodeErrorData: dco_decode_crypto_error, + ), + constMeta: kCrateApiEvfsVaultImportConstMeta, + argValues: [ + archivePath, + wrappingKey, + destPath, + newMasterKey, + algorithm, + capacityBytes, + ], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiEvfsVaultImportConstMeta => const TaskConstMeta( + debugName: "vault_import", + argNames: [ + "archivePath", + "wrappingKey", + "destPath", + "newMasterKey", + "algorithm", + "capacityBytes", + ], + ); + @override Future> crateApiEvfsVaultList({required VaultHandle handle}) { return handler.executeNormal( @@ -1781,6 +1898,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["handle", "newCapacity"], ); + @override + Future crateApiEvfsVaultRotateKey({ + required VaultHandle handle, + required List newKey, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = + cst_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle( + handle, + ); + var arg1 = cst_encode_list_prim_u_8_loose(newKey); + return wire.wire__crate__api__evfs__vault_rotate_key( + port_, + arg0, + arg1, + ); + }, + codec: DcoCodec( + decodeSuccessData: + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerVaultHandle, + decodeErrorData: dco_decode_crypto_error, + ), + constMeta: kCrateApiEvfsVaultRotateKeyConstMeta, + argValues: [handle, newKey], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiEvfsVaultRotateKeyConstMeta => const TaskConstMeta( + debugName: "vault_rotate_key", + argNames: ["handle", "newKey"], + ); + @override Future crateApiEvfsVaultWrite({ required VaultHandle handle, @@ -2086,6 +2239,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return CryptoError_SegmentNotFound(dco_decode_String(raw[1])); case 13: return CryptoError_VaultCorrupted(dco_decode_String(raw[1])); + case 14: + return CryptoError_KeyRotationFailed(dco_decode_String(raw[1])); + case 15: + return CryptoError_ExportFailed(dco_decode_String(raw[1])); + case 16: + return CryptoError_ImportFailed(dco_decode_String(raw[1])); default: throw Exception("unreachable"); } @@ -2466,6 +2625,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case 13: var var_field0 = sse_decode_String(deserializer); return CryptoError_VaultCorrupted(var_field0); + case 14: + var var_field0 = sse_decode_String(deserializer); + return CryptoError_KeyRotationFailed(var_field0); + case 15: + var var_field0 = sse_decode_String(deserializer); + return CryptoError_ExportFailed(var_field0); + case 16: + var var_field0 = sse_decode_String(deserializer); + return CryptoError_ImportFailed(var_field0); default: throw UnimplementedError(''); } @@ -3053,6 +3221,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case CryptoError_VaultCorrupted(field0: final field0): sse_encode_i_32(13, serializer); sse_encode_String(field0, serializer); + case CryptoError_KeyRotationFailed(field0: final field0): + sse_encode_i_32(14, serializer); + sse_encode_String(field0, serializer); + case CryptoError_ExportFailed(field0: final field0): + sse_encode_i_32(15, serializer); + sse_encode_String(field0, serializer); + case CryptoError_ImportFailed(field0: final field0): + sse_encode_i_32(16, serializer); + sse_encode_String(field0, serializer); } } diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index 497e30e..1de4593 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -562,6 +562,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wireObj.kind.VaultCorrupted.field0 = pre_field0; return; } + if (apiObj is CryptoError_KeyRotationFailed) { + var pre_field0 = cst_encode_String(apiObj.field0); + wireObj.tag = 14; + wireObj.kind.KeyRotationFailed.field0 = pre_field0; + return; + } + if (apiObj is CryptoError_ExportFailed) { + var pre_field0 = cst_encode_String(apiObj.field0); + wireObj.tag = 15; + wireObj.kind.ExportFailed.field0 = pre_field0; + return; + } + if (apiObj is CryptoError_ImportFailed) { + var pre_field0 = cst_encode_String(apiObj.field0); + wireObj.tag = 16; + wireObj.kind.ImportFailed.field0 = pre_field0; + return; + } } @protected @@ -1858,6 +1876,42 @@ class RustLibWire implements BaseWire { void Function(int, int, ffi.Pointer) >(); + void wire__crate__api__evfs__vault_export( + int port_, + int handle, + ffi.Pointer wrapping_key, + ffi.Pointer export_path, + ) { + return _wire__crate__api__evfs__vault_export( + port_, + handle, + wrapping_key, + export_path, + ); + } + + late final _wire__crate__api__evfs__vault_exportPtr = + _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Int64, + ffi.UintPtr, + ffi.Pointer, + ffi.Pointer, + ) + > + >('frbgen_m_security_wire__crate__api__evfs__vault_export'); + late final _wire__crate__api__evfs__vault_export = + _wire__crate__api__evfs__vault_exportPtr + .asFunction< + void Function( + int, + int, + ffi.Pointer, + ffi.Pointer, + ) + >(); + void wire__crate__api__evfs__vault_health(int port_, int handle) { return _wire__crate__api__evfs__vault_health(port_, handle); } @@ -1870,6 +1924,54 @@ class RustLibWire implements BaseWire { _wire__crate__api__evfs__vault_healthPtr .asFunction(); + void wire__crate__api__evfs__vault_import( + int port_, + ffi.Pointer archive_path, + ffi.Pointer wrapping_key, + ffi.Pointer dest_path, + ffi.Pointer new_master_key, + ffi.Pointer algorithm, + int capacity_bytes, + ) { + return _wire__crate__api__evfs__vault_import( + port_, + archive_path, + wrapping_key, + dest_path, + new_master_key, + algorithm, + capacity_bytes, + ); + } + + late final _wire__crate__api__evfs__vault_importPtr = + _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Int64, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Uint64, + ) + > + >('frbgen_m_security_wire__crate__api__evfs__vault_import'); + late final _wire__crate__api__evfs__vault_import = + _wire__crate__api__evfs__vault_importPtr + .asFunction< + void Function( + int, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + int, + ) + >(); + void wire__crate__api__evfs__vault_list(int port_, int handle) { return _wire__crate__api__evfs__vault_list(port_, handle); } @@ -1996,6 +2098,30 @@ class RustLibWire implements BaseWire { _wire__crate__api__evfs__vault_resizePtr .asFunction(); + void wire__crate__api__evfs__vault_rotate_key( + int port_, + int handle, + ffi.Pointer new_key, + ) { + return _wire__crate__api__evfs__vault_rotate_key(port_, handle, new_key); + } + + late final _wire__crate__api__evfs__vault_rotate_keyPtr = + _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Int64, + ffi.UintPtr, + ffi.Pointer, + ) + > + >('frbgen_m_security_wire__crate__api__evfs__vault_rotate_key'); + late final _wire__crate__api__evfs__vault_rotate_key = + _wire__crate__api__evfs__vault_rotate_keyPtr + .asFunction< + void Function(int, int, ffi.Pointer) + >(); + void wire__crate__api__evfs__vault_write( int port_, int handle, @@ -2342,6 +2468,18 @@ final class wire_cst_CryptoError_VaultCorrupted extends ffi.Struct { external ffi.Pointer field0; } +final class wire_cst_CryptoError_KeyRotationFailed extends ffi.Struct { + external ffi.Pointer field0; +} + +final class wire_cst_CryptoError_ExportFailed extends ffi.Struct { + external ffi.Pointer field0; +} + +final class wire_cst_CryptoError_ImportFailed extends ffi.Struct { + external ffi.Pointer field0; +} + final class CryptoErrorKind extends ffi.Union { external wire_cst_CryptoError_InvalidKeyLength InvalidKeyLength; @@ -2362,6 +2500,12 @@ final class CryptoErrorKind extends ffi.Union { external wire_cst_CryptoError_SegmentNotFound SegmentNotFound; external wire_cst_CryptoError_VaultCorrupted VaultCorrupted; + + external wire_cst_CryptoError_KeyRotationFailed KeyRotationFailed; + + external wire_cst_CryptoError_ExportFailed ExportFailed; + + external wire_cst_CryptoError_ImportFailed ImportFailed; } final class wire_cst_crypto_error extends ffi.Struct { @@ -2434,6 +2578,14 @@ const int MIN_LEVEL = 1; const int MAX_LEVEL = 22; +const int ARCHIVE_VERSION = 1; + +const int ARCHIVE_HEADER_SIZE = 32; + +const int WRAPPED_KEY_SIZE = 60; + +const int ARCHIVE_TRAILER_SIZE = 36; + const int VAULT_VERSION = 1; const int VAULT_HEADER_SIZE = 32; diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 86c7dbe..4178554 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -452,6 +452,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { if (raw is CryptoError_VaultCorrupted) { return [13, cst_encode_String(raw.field0)].jsify()!; } + if (raw is CryptoError_KeyRotationFailed) { + return [14, cst_encode_String(raw.field0)].jsify()!; + } + if (raw is CryptoError_ExportFailed) { + return [15, cst_encode_String(raw.field0)].jsify()!; + } + if (raw is CryptoError_ImportFailed) { + return [16, cst_encode_String(raw.field0)].jsify()!; + } throw Exception('unreachable'); } @@ -1131,9 +1140,39 @@ class RustLibWire implements BaseWire { String name, ) => wasmModule.wire__crate__api__evfs__vault_delete(port_, handle, name); + void wire__crate__api__evfs__vault_export( + NativePortType port_, + int handle, + JSAny wrapping_key, + String export_path, + ) => wasmModule.wire__crate__api__evfs__vault_export( + port_, + handle, + wrapping_key, + export_path, + ); + void wire__crate__api__evfs__vault_health(NativePortType port_, int handle) => wasmModule.wire__crate__api__evfs__vault_health(port_, handle); + void wire__crate__api__evfs__vault_import( + NativePortType port_, + String archive_path, + JSAny wrapping_key, + String dest_path, + JSAny new_master_key, + String algorithm, + JSAny capacity_bytes, + ) => wasmModule.wire__crate__api__evfs__vault_import( + port_, + archive_path, + wrapping_key, + dest_path, + new_master_key, + algorithm, + capacity_bytes, + ); + void wire__crate__api__evfs__vault_list(NativePortType port_, int handle) => wasmModule.wire__crate__api__evfs__vault_list(port_, handle); @@ -1175,6 +1214,16 @@ class RustLibWire implements BaseWire { new_capacity, ); + void wire__crate__api__evfs__vault_rotate_key( + NativePortType port_, + int handle, + JSAny new_key, + ) => wasmModule.wire__crate__api__evfs__vault_rotate_key( + port_, + handle, + new_key, + ); + void wire__crate__api__evfs__vault_write( NativePortType port_, int handle, @@ -1480,11 +1529,28 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { String name, ); + external void wire__crate__api__evfs__vault_export( + NativePortType port_, + int handle, + JSAny wrapping_key, + String export_path, + ); + external void wire__crate__api__evfs__vault_health( NativePortType port_, int handle, ); + external void wire__crate__api__evfs__vault_import( + NativePortType port_, + String archive_path, + JSAny wrapping_key, + String dest_path, + JSAny new_master_key, + String algorithm, + JSAny capacity_bytes, + ); + external void wire__crate__api__evfs__vault_list( NativePortType port_, int handle, @@ -1517,6 +1583,12 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { JSAny new_capacity, ); + external void wire__crate__api__evfs__vault_rotate_key( + NativePortType port_, + int handle, + JSAny new_key, + ); + external void wire__crate__api__evfs__vault_write( NativePortType port_, int handle, diff --git a/macos/m_security.podspec b/macos/m_security.podspec index ab678bf..3321b45 100644 --- a/macos/m_security.podspec +++ b/macos/m_security.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'm_security' - s.version = '0.3.3' + s.version = '0.3.4' s.summary = 'A high-performance cryptographic SDK for Flutter powered by native Rust via FFI.' s.description = <<-DESC A high-performance cryptographic SDK for Flutter powered by native Rust via FFI. diff --git a/pubspec.yaml b/pubspec.yaml index 75b63b2..cd4ba7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: m_security description: >- A native Rust cryptographic SDK for Flutter via FFI. AES-256-GCM, ChaCha20-Poly1305, BLAKE3, SHA-3, Argon2id, HKDF, streaming encryption, and encrypted virtual file system. -version: 0.3.3 +version: 0.3.4 homepage: https://github.com/MicroClub-USTHB/M-Security repository: https://github.com/MicroClub-USTHB/M-Security issue_tracker: https://github.com/MicroClub-USTHB/M-Security/issues diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fd95d30..4827405 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -731,7 +731,7 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "m_security" -version = "0.3.3" +version = "0.3.4" dependencies = [ "aes-gcm", "argon2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5d7614f..d0fbd40 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "m_security" -version = "0.3.3" +version = "0.3.4" edition = "2021" [lib] diff --git a/rust/src/api/evfs/helpers.rs b/rust/src/api/evfs/helpers.rs index fbe54ed..ae4d2a7 100644 --- a/rust/src/api/evfs/helpers.rs +++ b/rust/src/api/evfs/helpers.rs @@ -4,6 +4,7 @@ use crate::core::evfs::segment::{self, VaultKeys}; use crate::core::format::Algorithm; use std::fs::File; use std::io::{Read, Seek, SeekFrom, Write}; +use zeroize::Zeroize; #[cfg(feature = "compression")] use crate::api::compression::CompressionAlgorithm; @@ -161,12 +162,8 @@ pub(crate) fn decrypt_streaming_chunks( } .to_bytes(); - let decrypted = segment::aead_decrypt_with_stored_nonce( - cipher_key, - encrypted_ref, - &aad, - algorithm, - )?; + let decrypted = + segment::aead_decrypt_with_stored_nonce(cipher_key, encrypted_ref, &aad, algorithm)?; let plaintext = if is_final { crate::core::streaming::strip_last_chunk_padding(&decrypted)? @@ -192,3 +189,41 @@ pub(crate) fn decrypt_streaming_chunks( Ok(hasher.finalize().into()) } + +/// Decrypt a raw encrypted monolithic segment blob (`nonce || ciphertext || tag`) +/// without going through a `VaultHandle` read path. +/// +/// # Errors +/// +/// - [`CryptoError::AuthenticationFailed`] — nonce mismatch or AEAD tag failure. +/// - [`CryptoError::VaultCorrupted`] — BLAKE3 checksum mismatch after decryption. +/// - Any lower-level [`CryptoError`] propagated from decompression or AEAD. +#[cfg(feature = "compression")] +pub(crate) fn decrypt_segment_raw( + encrypted: &[u8], + cipher_key: &[u8], + nonce_key: &[u8], + algorithm: Algorithm, + generation: u64, + compression: CompressionAlgorithm, + expected_checksum: &[u8; 32], +) -> Result, CryptoError> { + let params = segment::SegmentCryptoParams { + cipher_key, + nonce_key, + algorithm, + segment_index: 0, // monolithic segments always use index 0 for nonce derivation + generation, + }; + + let mut plaintext = segment::decrypt_segment(¶ms, encrypted, compression)?; + + if !segment::verify_checksum(&plaintext, expected_checksum) { + plaintext.zeroize(); + return Err(CryptoError::VaultCorrupted( + "decrypt_segment_raw: BLAKE3 integrity check failed".into(), + )); + } + + Ok(plaintext) +} diff --git a/rust/src/api/evfs/mod.rs b/rust/src/api/evfs/mod.rs index b39721e..0c6d1e3 100644 --- a/rust/src/api/evfs/mod.rs +++ b/rust/src/api/evfs/mod.rs @@ -6,8 +6,8 @@ mod tests; pub mod types; use helpers::*; -pub use types::*; use types::VaultMmap; +pub use types::*; use crate::api::compression::{CompressionAlgorithm, CompressionConfig}; use crate::core::error::CryptoError; @@ -19,7 +19,7 @@ use crate::core::evfs::wal::{VaultLock, WalOp, WriteAheadLog}; use crate::core::format::Algorithm; use crate::frb_generated::StreamSink; use subtle::ConstantTimeEq; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; use std::fs::{File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; @@ -94,6 +94,16 @@ pub fn vault_create( pub fn vault_open(path: String, mut key: Vec) -> Result { let lock = VaultLock::acquire(&path)?; + // If a previous key rotation was interrupted after pre-allocation but before + // the atomic rename completed, a stale .rotating vault will be sitting next + // to the original. The original is intact — just remove the orphan so it + // does not interfere with a future rotation attempt. + let rotating_path = format!("{path}.rotating"); + if std::path::Path::new(&rotating_path).exists() { + let _ = std::fs::remove_file(&rotating_path); + let _ = std::fs::remove_file(format!("{rotating_path}.wal")); + } + let mut file = OpenOptions::new() .read(true) .write(true) @@ -253,9 +263,12 @@ pub fn vault_open(path: String, mut key: Vec) -> Result, + data: Vec, compression: Option, ) -> Result<(), CryptoError> { + // SAFETY: Zeroizing guarantees plaintext is wiped on all exit paths + let data = Zeroizing::new(data); + let config = compression.unwrap_or(CompressionConfig { algorithm: CompressionAlgorithm::None, level: None, @@ -274,7 +287,7 @@ pub fn vault_write( generation: gen, }; let (encrypted, effective_algo) = segment::encrypt_segment(¶ms, &data, &name, &config)?; - data.zeroize(); + drop(data); // 3. WAL journal old index let old_encrypted_index = read_encrypted_index( @@ -1069,6 +1082,467 @@ pub fn vault_defragment(handle: &mut VaultHandle) -> Result, +) -> Result { + // We acquire a lock on both the current vault and the new vault that we do the transition with. + let temp_path = format!("{}.rotating", &handle.path); + + // Clean up a stale .rotating file from a previous failed rotation so that + // create_new(true) below doesn't fail. + if std::path::Path::new(&temp_path).exists() { + let _ = std::fs::remove_file(&temp_path); + let _ = std::fs::remove_file(format!("{temp_path}.wal")); + } + + let temp_lock = VaultLock::acquire(&temp_path)?; + + // Generate the new key + let capacity = handle.index.capacity; + let new_keys = match segment::derive_vault_keys(&new_key) { + Ok(k) => { + new_key.zeroize(); + k + } + Err(e) => { + new_key.zeroize(); + return Err(e); + } + }; + // Re-calc the same constants. + let index_pad_size = format::compute_index_size(capacity); + let total_size = format::total_vault_size(capacity, index_pad_size)?; + let data_off = format::data_region_offset(index_pad_size); + + // Create the temporary/rotating vault file, pre-allocate the size, and write the header. + // We need the vault to be setup before writing segments. + let mut temp_file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&temp_path) + .map_err(|e| CryptoError::IoError(format!("cannot create vault: {e}")))?; + segment::preallocate_vault(&mut temp_file, total_size)?; + let header = VaultHeader::new(handle.algorithm.to_byte(), index_pad_size as u32); + temp_file.seek(SeekFrom::Start(0))?; + temp_file.write_all(&header.to_bytes())?; + + // Get all the segments from the previous vault. + let old_file = &mut handle.file; + let segment_entries = handle.index.entries.drain(..); + let mut new_index = SegmentIndex::new(capacity); + + for old_entry in segment_entries { + if old_entry.is_streaming() { + // NOTE: Streaming segments must be fully decrypted before re-chunking because + // the chunk boundaries are tied to the plaintext size, not the ciphertext layout. + let mut full_plaintext: Zeroizing> = Zeroizing::new(Vec::new()); + decrypt_streaming_chunks( + old_file, + handle.mmap.as_ref(), + handle.keys.cipher_key.as_bytes(), + handle.keys.nonce_key.as_bytes(), + handle.algorithm, + index_pad_size, + old_entry.offset, + old_entry.generation, + old_entry.compression, + old_entry.chunk_count, + |chunk, _| { + full_plaintext.extend_from_slice(&chunk); + Ok(()) + }, + )?; + + let total_plaintext_size = full_plaintext.len() as u64; + let expected_chunks = format::streaming_chunk_count(total_plaintext_size)?; + let total_enc_size = format::streaming_segment_size(total_plaintext_size)?; + + let new_gen = new_index.next_gen(); + let new_offset = new_index.allocate(total_enc_size)?; + + // Re-encrypt chunk-by-chunk into the rotating vault using the new keys. + use crate::core::streaming::{pad_last_chunk, CHUNK_SIZE}; + let mut chunk_buf: Zeroizing> = Zeroizing::new(vec![0u8; CHUNK_SIZE]); + let mut buf_len: usize = 0; + let mut chunk_index: u64 = 0; + let mut pos: usize = 0; + + while pos < full_plaintext.len() { + let take = (CHUNK_SIZE - buf_len).min(full_plaintext.len() - pos); + chunk_buf[buf_len..buf_len + take] + .copy_from_slice(&full_plaintext[pos..pos + take]); + buf_len += take; + pos += take; + + if buf_len == CHUNK_SIZE { + let abs_off = chunk_abs_offset(data_off, new_offset, chunk_index)?; + write_encrypted_chunk( + &mut temp_file, + new_keys.cipher_key.as_bytes(), + new_keys.nonce_key.as_bytes(), + handle.algorithm, + &chunk_buf, + chunk_index, + new_gen, + false, + abs_off, + )?; + chunk_index += 1; + buf_len = 0; + } + } + // full_plaintext is zeroized on drop via Zeroizing>. + drop(full_plaintext); + + // The final chunk always exists (even for empty segments) and must be padded. + let padded = Zeroizing::new(pad_last_chunk(&chunk_buf[..buf_len])?); + // chunk_buf is zeroized on drop via Zeroizing>. + drop(chunk_buf); + let final_off = chunk_abs_offset(data_off, new_offset, chunk_index)?; + write_encrypted_chunk( + &mut temp_file, + new_keys.cipher_key.as_bytes(), + new_keys.nonce_key.as_bytes(), + handle.algorithm, + &padded, + chunk_index, + new_gen, + true, + final_off, + )?; + drop(padded); + + // Preserve the BLAKE3 checksum — it covers the plaintext, which hasn't changed. + // Compression is None because decrypt_streaming_chunks already decompressed. + let new_entry = SegmentEntry::new( + &old_entry.name, + new_offset, + total_enc_size, + new_gen, + old_entry.checksum, + CompressionAlgorithm::None, + expected_chunks, + )?; + new_index.add(new_entry)?; + } else { + let abs_offset = data_off + old_entry.offset; + let encrypted: Vec = if let Some(ref mmap) = handle.mmap { + mmap.slice(abs_offset, old_entry.size)?.to_vec() + } else { + old_file.seek(SeekFrom::Start(abs_offset))?; + let mut buf = vec![0u8; old_entry.size as usize]; + old_file.read_exact(&mut buf)?; + buf + }; + + // Decrypt with the old keys (also decompresses and verifies the BLAKE3 checksum). + let plaintext = Zeroizing::new(decrypt_segment_raw( + &encrypted, + handle.keys.cipher_key.as_bytes(), + handle.keys.nonce_key.as_bytes(), + handle.algorithm, + old_entry.generation, + old_entry.compression, + &old_entry.checksum, + )?); + + // Re-encrypt with the new keys. No compression: plaintext is already decompressed. + let new_gen = new_index.next_gen(); + let params = SegmentCryptoParams { + cipher_key: new_keys.cipher_key.as_bytes(), + nonce_key: new_keys.nonce_key.as_bytes(), + algorithm: handle.algorithm, + segment_index: 0, + generation: new_gen, + }; + let (new_encrypted, _) = segment::encrypt_segment( + ¶ms, + &plaintext, + &old_entry.name, + &CompressionConfig { + algorithm: CompressionAlgorithm::None, + level: None, + }, + )?; + + let new_offset = new_index.allocate(new_encrypted.len() as u64)?; + temp_file.seek(SeekFrom::Start(data_off + new_offset))?; + temp_file.write_all(&new_encrypted)?; + + // Preserve the BLAKE3 checksum — it covers the plaintext, which hasn't changed. + let new_entry = SegmentEntry::new( + &old_entry.name, + new_offset, + new_encrypted.len() as u64, + new_gen, + old_entry.checksum, + CompressionAlgorithm::None, + 0, + )?; + new_index.add(new_entry)?; + } + } + + // After writing all segments, sync the file and do maintenance stuff. + flush_index( + &mut temp_file, + &new_index, + &new_keys, + handle.algorithm, + capacity, + index_pad_size, + )?; + temp_file.sync_all()?; + + // Open the WAL for the rotating file to ensure everything is fine. + let mut rotating_wal = WriteAheadLog::open(&temp_path)?; + rotating_wal.checkpoint()?; + drop(rotating_wal); + let _ = std::fs::remove_file(format!("{temp_path}.wal")); + drop(temp_file); // close the rotating vault fd before rename + temp_lock.release()?; + + // Checkpoint the original WAL while we still hold the original lock. + handle.wal.checkpoint()?; + let algorithm = handle.algorithm; + let original_path = handle.path.clone(); + + // Atomic rename is the commit point — must happen while the original lock is + // still held to prevent a concurrent vault_open() from seeing the stale file + // or deleting the .rotating file. + std::fs::rename(&temp_path, &original_path) + .map_err(|e| CryptoError::KeyRotationFailed(format!("rename failed: {e}")))?; + + // Release the old lock only after the rename succeeded. + handle.lock.release()?; + // SAFETY: Remaining VaultHandle fields (keys, mmap, file) are dropped at end + // of scope — SecretBuffer/VaultKeys are ZeroizeOnDrop. + let new_lock = VaultLock::acquire(&original_path)?; + let new_file = OpenOptions::new() + .read(true) + .write(true) + .open(&original_path) + .map_err(|e| CryptoError::IoError(format!("cannot reopen vault after rotation: {e}")))?; + // The WAL at {original_path}.wal was already checkpointed above; open and + // checkpoint once more to guard against any edge-case leftover entries. + let mut new_wal = WriteAheadLog::open(&original_path)?; + new_wal.checkpoint()?; + let new_mmap = VaultMmap::new(&new_file).ok(); + + Ok(VaultHandle { + path: original_path, + algorithm, + keys: new_keys, + index: new_index, + index_pad_size, + mmap: new_mmap, + file: new_file, + wal: new_wal, + lock: new_lock, + }) +} + +/// Export all vault segments into a self-contained `.mvex` encrypted archive. +/// +/// Each segment is decrypted from the vault, then re-encrypted under an +/// ephemeral export key with a random per-segment nonce. The export key is +/// AEAD-wrapped with the caller's `wrapping_key`. +/// +/// The vault is not modified by this operation. +#[cfg(feature = "compression")] +pub fn vault_export( + handle: &mut VaultHandle, + wrapping_key: Vec, + export_path: String, +) -> Result<(), CryptoError> { + use crate::core::evfs::archive::{ArchiveHeader, KEY_WRAP_AAD}; + use rand::{rngs::OsRng, RngCore}; + + // Wrap immediately so wrapping_key is zeroized on all paths (including errors). + let wrapping_key = Zeroizing::new(wrapping_key); + + if wrapping_key.len() != 32 { + return Err(CryptoError::InvalidKeyLength { + expected: 32, + actual: wrapping_key.len(), + }); + } + + // 1. Generate random 32-byte ephemeral export key + let mut export_key = Zeroizing::new(vec![0u8; 32]); + OsRng.fill_bytes(&mut export_key); + + // 2. Wrap export_key with wrapping_key (AEAD, AAD = KEY_WRAP_AAD) + // Result: nonce(12) || ciphertext(32) || tag(16) = 60 bytes + let wrapped_key = segment::aead_encrypt_random_nonce( + &wrapping_key, + &export_key, + KEY_WRAP_AAD, + handle.algorithm, + )?; + + // 3. Prepare header + let segment_count = u32::try_from(handle.index.entries.len()) + .map_err(|_| CryptoError::ExportFailed("segment count exceeds u32".into()))?; + let header = ArchiveHeader::new(handle.algorithm.to_byte(), segment_count); + + // Use create_new to fail atomically if the file already exists. + let mut out = OpenOptions::new() + .write(true) + .create_new(true) + .open(&export_path) + .map_err(|e| CryptoError::IoError(format!("cannot create export file: {e}")))?; + + // Run the inner export logic; delete the partial file on any error. + match vault_export_write(handle, &export_key, &wrapped_key, &header, &mut out) { + Ok(()) => Ok(()), + Err(e) => { + drop(out); + let _ = std::fs::remove_file(&export_path); + Err(e) + } + } +} + +/// Write the archive contents. Separated from `vault_export` so the caller +/// can delete the partial file if this returns an error. +#[cfg(feature = "compression")] +fn vault_export_write( + handle: &mut VaultHandle, + export_key: &[u8], + wrapped_key: &[u8], + header: &crate::core::evfs::archive::ArchiveHeader, + out: &mut std::fs::File, +) -> Result<(), CryptoError> { + use crate::core::evfs::archive::{ArchiveTrailer, SegmentRecord}; + + let header_bytes = header.to_bytes(); + out.write_all(&header_bytes)?; + out.write_all(wrapped_key)?; + + // BLAKE3 hasher covers everything before the trailer + let mut archive_hasher = blake3::Hasher::new(); + archive_hasher.update(&header_bytes); + archive_hasher.update(wrapped_key); + + // Clone entry metadata to avoid borrow conflict with handle + let entries: Vec<_> = handle + .index + .entries + .iter() + .map(|e| { + ( + e.name.clone(), + e.offset, + e.size, + e.generation, + e.compression, + e.checksum, + e.chunk_count, + ) + }) + .collect(); + + let vault_capacity = handle.index.capacity; + + for (name, offset, size, generation, compression, checksum, chunk_count) in &entries { + // Decrypt the segment plaintext (handles both monolithic and streaming) + let plaintext: Zeroizing> = if *chunk_count > 0 { + let mut full_plaintext: Zeroizing> = Zeroizing::new(Vec::new()); + let computed_checksum = decrypt_streaming_chunks( + &mut handle.file, + handle.mmap.as_ref(), + handle.keys.cipher_key.as_bytes(), + handle.keys.nonce_key.as_bytes(), + handle.algorithm, + handle.index_pad_size, + *offset, + *generation, + *compression, + *chunk_count, + |data, _| { + full_plaintext.extend_from_slice(&data); + Ok(()) + }, + )?; + if computed_checksum.ct_ne(checksum).into() { + return Err(CryptoError::VaultCorrupted(format!( + "integrity check failed for segment '{name}'" + ))); + } + full_plaintext + } else { + // Cap allocation at vault capacity to prevent OOM from a corrupted index. + if *size > vault_capacity { + return Err(CryptoError::VaultCorrupted(format!( + "segment '{name}' size {size} exceeds vault capacity {vault_capacity}" + ))); + } + let abs_offset = format::data_region_offset(handle.index_pad_size) + offset; + let params = SegmentCryptoParams { + cipher_key: handle.keys.cipher_key.as_bytes(), + nonce_key: handle.keys.nonce_key.as_bytes(), + algorithm: handle.algorithm, + segment_index: 0, + generation: *generation, + }; + let pt = if let Some(ref mmap) = handle.mmap { + let encrypted = mmap.slice(abs_offset, *size)?; + segment::decrypt_segment(¶ms, encrypted, *compression)? + } else { + handle.file.seek(SeekFrom::Start(abs_offset))?; + let mut buf = vec![0u8; *size as usize]; + handle.file.read_exact(&mut buf)?; + segment::decrypt_segment(¶ms, &buf, *compression)? + }; + if !segment::verify_checksum(&pt, checksum) { + return Err(CryptoError::VaultCorrupted(format!( + "integrity check failed for segment '{name}'" + ))); + } + Zeroizing::new(pt) + }; + + // Compute BLAKE3 checksum before re-encryption (while plaintext is live) + let record_checksum = segment::compute_checksum(&plaintext); + + // Re-encrypt under the ephemeral export key with segment name as AAD + let encrypted_data = segment::aead_encrypt_random_nonce( + export_key, + &plaintext, + name.as_bytes(), + handle.algorithm, + )?; + // plaintext is zeroized on drop via Zeroizing + + let record = SegmentRecord { + name: name.clone(), + compression: compression.to_u8(), + checksum: record_checksum, + encrypted_data, + }; + + let record_header = record.write_header()?; + out.write_all(&record_header)?; + out.write_all(&record.encrypted_data)?; + + archive_hasher.update(&record_header); + archive_hasher.update(&record.encrypted_data); + } + + // Write BLAKE3 trailer + fsync + let trailer = ArchiveTrailer { + checksum: archive_hasher.finalize().into(), + }; + out.write_all(&trailer.to_bytes())?; + out.sync_all()?; + + Ok(()) +} + /// Close the vault — checkpoint WAL, release lock, zeroize keys on drop. #[cfg(feature = "compression")] pub fn vault_close(mut handle: VaultHandle) -> Result<(), CryptoError> { @@ -1077,3 +1551,221 @@ pub fn vault_close(mut handle: VaultHandle) -> Result<(), CryptoError> { // VaultKeys are zeroized on drop (ZeroizeOnDrop) Ok(()) } + +/// Unwraps export key, creates new vault at dest_path, writes all segments under new_master_key. +#[cfg(feature = "compression")] +pub fn vault_import( + archive_path: String, + wrapping_key: Vec, + dest_path: String, + new_master_key: Vec, + algorithm: String, + capacity_bytes: u64, +) -> Result { + use crate::core::evfs::archive::{ + ArchiveHeader, ArchiveTrailer, ARCHIVE_HEADER_SIZE, ARCHIVE_TRAILER_SIZE, KEY_WRAP_AAD, + WRAPPED_KEY_SIZE, + }; + + // Ensure automatic zeroization on drop + let wrapping_key = Zeroizing::new(wrapping_key); + + let mut archive = File::open(&archive_path) + .map_err(|e| CryptoError::IoError(format!("cannot open archive '{archive_path}': {e}")))?; + + // Read & Validate Header + let mut header_buf = [0u8; ARCHIVE_HEADER_SIZE]; + archive + .read_exact(&mut header_buf) + .map_err(|_| CryptoError::ImportFailed("Truncated archive header".into()))?; + let header = ArchiveHeader::from_bytes(&header_buf) + .map_err(|_| CryptoError::ImportFailed("invalid archive header".into()))?; + + if header.segment_count > 100_000 { + return Err(CryptoError::ImportFailed( + "archive segment count exceeds sanity limit".into(), + )); + } + + // Read WrappedExportKey + let mut wrapped_key = [0u8; WRAPPED_KEY_SIZE]; + archive + .read_exact(&mut wrapped_key) + .map_err(|_| CryptoError::ImportFailed("Truncated wrapped key".into()))?; + + let mut archive_hasher = blake3::Hasher::new(); + archive_hasher.update(&header_buf); + archive_hasher.update(&wrapped_key); + + let algo = Algorithm::from_byte(header.algorithm).map_err(|_| { + CryptoError::ImportFailed(format!("Unsupported algorithm byte: {}", header.algorithm)) + })?; + + // Unwrap the ephemeral export key + let export_key = Zeroizing::new( + segment::aead_decrypt_with_stored_nonce(&wrapping_key, &wrapped_key, KEY_WRAP_AAD, algo) + .map_err(|_| { + CryptoError::ImportFailed("Invalid wrapping key or corrupted wrapped key".into()) + })?, + ); + + // Create new destination vault + let mut dest_vault = + vault_create(dest_path.clone(), new_master_key, algorithm, capacity_bytes)?; + + // Process all segments inside a closure to handle atomic crash recovery cleanup + let mut process_records = || -> Result<(), CryptoError> { + for _ in 0..header.segment_count { + // Read SegmentRecordHeader components sequentially + let mut name_len_buf = [0u8; 2]; + archive + .read_exact(&mut name_len_buf) + .map_err(|_| CryptoError::ImportFailed("Truncated segment record".into()))?; + let name_len = u16::from_le_bytes(name_len_buf) as usize; + + let mut name_buf = vec![0u8; name_len]; + archive + .read_exact(&mut name_buf) + .map_err(|_| CryptoError::ImportFailed("Truncated segment name".into()))?; + let name = String::from_utf8(name_buf) + .map_err(|_| CryptoError::ImportFailed("Invalid UTF-8 in segment name".into()))?; + + if name.is_empty() || name.len() > 255 { + return Err(CryptoError::ImportFailed(format!( + "invalid segment name length: {}", + name.len() + ))); + } + + let mut comp_buf = [0u8; 1]; + archive + .read_exact(&mut comp_buf) + .map_err(|_| CryptoError::ImportFailed("Truncated segment compression".into()))?; + let compression = comp_buf[0]; + + let mut checksum = [0u8; 32]; + archive + .read_exact(&mut checksum) + .map_err(|_| CryptoError::ImportFailed("Truncated segment checksum".into()))?; + + let mut data_len_buf = [0u8; 8]; + archive + .read_exact(&mut data_len_buf) + .map_err(|_| CryptoError::ImportFailed("Truncated segment data length".into()))?; + let data_len_u64 = u64::from_le_bytes(data_len_buf); + + // OOM guard — u64 arithmetic avoids truncation on 32-bit targets + if data_len_u64 > capacity_bytes.saturating_add(65536) { + return Err(CryptoError::VaultFull { + needed: data_len_u64, + available: capacity_bytes, + }); + } + let data_len = usize::try_from(data_len_u64).map_err(|_| { + CryptoError::ImportFailed("segment data_len exceeds address space".into()) + })?; + + let mut encrypted_data = vec![0u8; data_len]; + archive.read_exact(&mut encrypted_data).map_err(|_| { + CryptoError::ImportFailed("Truncated segment encrypted data".into()) + })?; + + // Feed global trailer hasher + let mut header_bytes = Vec::new(); + header_bytes.extend_from_slice(&name_len_buf); + header_bytes.extend_from_slice(name.as_bytes()); + header_bytes.push(compression); + header_bytes.extend_from_slice(&checksum); + header_bytes.extend_from_slice(&data_len_buf); + + archive_hasher.update(&header_bytes); + archive_hasher.update(&encrypted_data); + + // 4. Decrypt using export_key + let mut plaintext = segment::aead_decrypt_with_stored_nonce( + &export_key, + &encrypted_data, + name.as_bytes(), + algo, + ) + .map_err(|_| { + CryptoError::ImportFailed(format!("Authentication failed for segment '{name}'")) + })?; + + // Verify BLAKE3 checksum (constant-time verification) + let computed_checksum = segment::compute_checksum(&plaintext); + if computed_checksum.ct_ne(&checksum).into() { + plaintext.zeroize(); + return Err(CryptoError::ImportFailed(format!( + "Checksum mismatch for segment '{name}'" + ))); + } + + let comp_algo = match compression { + 0 => CompressionAlgorithm::None, + 1 => CompressionAlgorithm::Zstd, + 2 => CompressionAlgorithm::Brotli, + _ => { + return Err(CryptoError::ImportFailed(format!( + "unknown compression byte: {compression}" + ))) + } + }; + + // vault_write takes ownership of plaintext and handles its zeroization internally + vault_write( + &mut dest_vault, + name, + plaintext, + Some(CompressionConfig { + algorithm: comp_algo, + level: None, + }), + )?; + } + + // 5. Verify archive trailer + let mut trailer_buf = [0u8; ARCHIVE_TRAILER_SIZE]; + archive + .read_exact(&mut trailer_buf) + .map_err(|_| CryptoError::ImportFailed("Truncated archive trailer".into()))?; + let trailer = ArchiveTrailer::from_bytes(&trailer_buf) + .map_err(|_| CryptoError::ImportFailed("invalid archive trailer".into()))?; + + let final_hash = archive_hasher.finalize(); + if trailer.checksum.ct_ne(final_hash.as_bytes()).into() { + return Err(CryptoError::ImportFailed( + "Archive trailer checksum mismatch".into(), + )); + } + + Ok(()) + }; + + match process_records() { + Ok(()) => { + // Guard against appended malicious trailing payload + let mut extra = [0u8; 1]; + if archive.read_exact(&mut extra).is_ok() { + drop(dest_vault); + let _ = std::fs::remove_file(&dest_path); + let _ = std::fs::remove_file(format!("{dest_path}.lock")); + let _ = std::fs::remove_file(format!("{dest_path}.wal")); + let _ = std::fs::remove_file(format!("{dest_path}.defrag")); + return Err(CryptoError::ImportFailed( + "Trailing garbage at end of archive".into(), + )); + } + Ok(dest_vault) + } + Err(e) => { + // Crash Recovery: safely delete partial/corrupted destination vault + drop(dest_vault); + let _ = std::fs::remove_file(&dest_path); + let _ = std::fs::remove_file(format!("{dest_path}.lock")); + let _ = std::fs::remove_file(format!("{dest_path}.wal")); + let _ = std::fs::remove_file(format!("{dest_path}.defrag")); + Err(e) + } + } +} diff --git a/rust/src/api/evfs/tests.rs b/rust/src/api/evfs/tests.rs index 6f6b73f..c8a690e 100644 --- a/rust/src/api/evfs/tests.rs +++ b/rust/src/api/evfs/tests.rs @@ -5,6 +5,10 @@ fn test_key() -> Vec { vec![0xAA; 32] } +fn test_key2() -> Vec { + vec![0xAB; 32] +} + fn wrong_key() -> Vec { vec![0xBB; 32] } @@ -2171,7 +2175,10 @@ fn test_stream_read_matches_oneshot_read() { let oneshot = vault_read(&mut handle, "video.bin".into()).expect("oneshot read"); let (streamed, _, _) = stream_read_chunks(&mut handle, "video.bin").expect("stream read"); - assert_eq!(streamed, oneshot, "streaming and one-shot must be byte-identical"); + assert_eq!( + streamed, oneshot, + "streaming and one-shot must be byte-identical" + ); assert_eq!(streamed, data); vault_close(handle).expect("close"); @@ -2204,8 +2211,7 @@ fn test_stream_read_progress_indices() { stream_write_chunks(&mut handle, "prog.bin", &data, chunk_size).expect("stream write"); let chunk_count = handle.index.find("prog.bin").expect("find").chunk_count; - let (collected, indices, _) = - stream_read_chunks(&mut handle, "prog.bin").expect("stream read"); + let (collected, indices, _) = stream_read_chunks(&mut handle, "prog.bin").expect("stream read"); assert_eq!(collected, data); assert_eq!(indices.len(), chunk_count as usize); @@ -2223,8 +2229,7 @@ fn test_stream_read_single_byte() { let data = vec![0xCC; 1]; stream_write_chunks(&mut handle, "tiny.bin", &data, 1).expect("stream write"); - let (collected, indices, _) = - stream_read_chunks(&mut handle, "tiny.bin").expect("stream read"); + let (collected, indices, _) = stream_read_chunks(&mut handle, "tiny.bin").expect("stream read"); assert_eq!(collected, data); assert_eq!(indices.len(), 1); @@ -2368,15 +2373,18 @@ fn test_stream_read_chacha20() { .to_str() .expect("path") .to_string(); - let mut handle = - vault_create(path, test_key(), "chacha20-poly1305".into(), 2 * 1024 * 1024) - .expect("create"); + let mut handle = vault_create( + path, + test_key(), + "chacha20-poly1305".into(), + 2 * 1024 * 1024, + ) + .expect("create"); let data = vec![0x77; 200_000]; stream_write_chunks(&mut handle, "chacha.bin", &data, 4096).expect("stream write"); - let (collected, _, _) = - stream_read_chunks(&mut handle, "chacha.bin").expect("stream read"); + let (collected, _, _) = stream_read_chunks(&mut handle, "chacha.bin").expect("stream read"); assert_eq!(collected, data); vault_close(handle).expect("close"); @@ -2487,16 +2495,906 @@ fn test_write_file_empty() { use crate::core::streaming::CHUNK_SIZE; let file_data = std::fs::read(&file_path).expect("read file"); let chunks: Vec> = file_data.chunks(CHUNK_SIZE).map(|c| c.to_vec()).collect(); + vault_write_stream(&mut handle, "empty.bin".into(), 0, chunks.into_iter()) + .expect("write stream"); + + let readback = vault_read(&mut handle, "empty.bin".into()).expect("read"); + assert!(readback.is_empty()); + + vault_close(handle).expect("close"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// -- Key Rotation ----------------------------------------------------------- + +#[test] +fn test_rotate_key_basic() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write a"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write b"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + assert_eq!( + vault_read(&mut handle, "a.txt".into()).expect("read a"), + b"hello" + ); + assert_eq!( + vault_read(&mut handle, "b.txt".into()).expect("read b"), + b"world" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_old_key_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let mut handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_write( + &mut handle, + "secret.txt".into(), + b"top secret".to_vec(), + None, + ) + .expect("write"); + let handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + vault_close(handle).expect("close"); + } + + // Old key must be rejected + assert!(vault_open(path.clone(), test_key()).is_err()); + + // New key must still work and data must be intact + let mut handle = vault_open(path, test_key2()).expect("open with new key"); + assert_eq!( + vault_read(&mut handle, "secret.txt".into()).expect("read"), + b"top secret" + ); + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_streaming_segment_survives() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2 * 1024 * 1024); + + let data = vec![0x42u8; 200_000]; // spans multiple chunks + stream_write_chunks(&mut handle, "video.bin", &data, 4096).expect("stream write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let readback = vault_read(&mut handle, "video.bin".into()).expect("read after rotate"); + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_compressed_segment_survives() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"compressible repeated payload ".repeat(100); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write(&mut handle, "data.bin".into(), data.clone(), Some(config)).expect("write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let readback = vault_read(&mut handle, "data.bin".into()).expect("read after rotate"); + assert_eq!(readback, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_checksum_preserved() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "doc.txt".into(), + b"checksum test data".to_vec(), + None, + ) + .expect("write"); + + let checksum_before = handle.index.find("doc.txt").expect("find before").checksum; + + let handle = vault_rotate_key(handle, test_key2()).expect("rotate"); + + let checksum_after = handle.index.find("doc.txt").expect("find after").checksum; + + assert_eq!( + checksum_before, checksum_after, + "BLAKE3 checksum must be identical before and after rotation" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_crash_recovery_rotating_cleaned_up() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = vault_path(&dir); + + { + let handle = vault_create(path.clone(), test_key(), "aes-256-gcm".into(), 1_048_576) + .expect("create"); + vault_close(handle).expect("close"); + } + + // Plant a stale .rotating file to simulate a crash mid-rotation. + let rotating_path = format!("{path}.rotating"); + std::fs::write(&rotating_path, b"stale junk").expect("plant stale file"); + assert!(std::path::Path::new(&rotating_path).exists()); + + // vault_open must silently remove the orphan and succeed normally. + let handle = vault_open(path, test_key()).expect("open after simulated crash"); + vault_close(handle).expect("close"); + + assert!( + !std::path::Path::new(&rotating_path).exists(), + ".rotating file must be cleaned up by vault_open" + ); +} + +#[test] +fn test_rotate_key_empty_vault() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + + // Rotate with no segments written at all. + let handle = vault_rotate_key(handle, test_key2()).expect("rotate empty vault"); + + assert!( + vault_list(&handle).is_empty(), + "rotated empty vault must have no segments" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join("chacha.vault") + .to_str() + .expect("path") + .to_string(); + + let mut handle = + vault_create(path, test_key(), "chacha20-poly1305".into(), 1_048_576).expect("create"); + vault_write( + &mut handle, + "msg.txt".into(), + b"chacha payload".to_vec(), + None, + ) + .expect("write"); + + let mut handle = vault_rotate_key(handle, test_key2()).expect("rotate chacha20 vault"); + + let readback = vault_read(&mut handle, "msg.txt".into()).expect("read after rotate"); + assert_eq!(readback, b"chacha payload"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_multiple_rotations() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "doc.txt".into(), + b"original data".to_vec(), + None, + ) + .expect("write"); + + // First rotation: test_key → new_key + let handle = vault_rotate_key(handle, test_key2()).expect("first rotation"); + // Second rotation: new_key → wrong_key + let mut handle = vault_rotate_key(handle, wrong_key()).expect("second rotation"); + + let readback = vault_read(&mut handle, "doc.txt".into()).expect("read after two rotations"); + assert_eq!(readback, b"original data"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_rotate_key_empty_new_key_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let handle = create_test_vault(&dir, 1_048_576); + + let result = vault_rotate_key(handle, vec![]); + assert!( + result.is_err(), + "rotation with an empty new key must return an error" + ); +} + +// -- Export ---------------------------------------------------------------- + +fn wrapping_key() -> Vec { + vec![0xCC; 32] +} + +fn export_path(dir: &tempfile::TempDir) -> String { + dir.path() + .join("export.mvex") + .to_str() + .expect("path") + .to_string() +} + +/// Parse an exported `.mvex` archive, returning (header, wrapped_key, records, trailer_checksum). +fn parse_archive( + path: &str, +) -> ( + crate::core::evfs::archive::ArchiveHeader, + Vec, + Vec, + [u8; 32], +) { + use crate::core::evfs::archive::*; + let data = std::fs::read(path).expect("read archive"); + assert!(data.len() >= ARCHIVE_HEADER_SIZE + WRAPPED_KEY_SIZE + ARCHIVE_TRAILER_SIZE); + + let header = ArchiveHeader::from_bytes( + data[..ARCHIVE_HEADER_SIZE].try_into().expect("header slice"), + ) + .expect("parse header"); + + let wk_start = ARCHIVE_HEADER_SIZE; + let wrapped_key = data[wk_start..wk_start + WRAPPED_KEY_SIZE].to_vec(); + + let mut pos = wk_start + WRAPPED_KEY_SIZE; + let mut records = Vec::new(); + + for _ in 0..header.segment_count { + let (name, compression, checksum, data_len, hdr_size) = + SegmentRecord::read_header(&data[pos..]).expect("parse record header"); + pos += hdr_size; + let enc_data = data[pos..pos + data_len as usize].to_vec(); + pos += data_len as usize; + records.push(SegmentRecord { + name, + compression, + checksum, + encrypted_data: enc_data, + }); + } + + let trailer_bytes: [u8; ARCHIVE_TRAILER_SIZE] = + data[pos..pos + ARCHIVE_TRAILER_SIZE].try_into().expect("trailer slice"); + let trailer = ArchiveTrailer::from_bytes(&trailer_bytes).expect("parse trailer"); + + // Verify BLAKE3 trailer covers everything before the trailer + let computed = blake3::hash(&data[..pos]); + assert_eq!( + trailer.checksum, + <[u8; 32]>::from(computed), + "trailer checksum mismatch" + ); + + (header, wrapped_key, records, trailer.checksum) +} + +/// Unwrap the export key and decrypt a segment record. +fn decrypt_record( + record: &crate::core::evfs::archive::SegmentRecord, + wrapped_key: &[u8], + wk: &[u8], + algorithm: crate::core::format::Algorithm, +) -> Vec { + use crate::core::evfs::archive::KEY_WRAP_AAD; + use crate::core::evfs::segment; + + let export_key = + segment::aead_decrypt_with_stored_nonce(wk, wrapped_key, KEY_WRAP_AAD, algorithm) + .expect("unwrap export key"); + segment::aead_decrypt_with_stored_nonce( + &export_key, + &record.encrypted_data, + record.name.as_bytes(), + algorithm, + ) + .expect("decrypt record") +} + +// -- Archive format unit tests --------------------------------------------- + +#[test] +fn test_archive_header_roundtrip() { + use crate::core::evfs::archive::*; + + let header = ArchiveHeader::new(0x01, 42); + let bytes = header.to_bytes(); + let parsed = ArchiveHeader::from_bytes(&bytes).expect("parse"); + assert_eq!(parsed.version, ARCHIVE_VERSION); + assert_eq!(parsed.algorithm, 0x01); + assert_eq!(parsed.segment_count, 42); + assert_eq!(parsed.flags, 0); +} + +#[test] +fn test_archive_header_bad_magic_rejected() { + use crate::core::evfs::archive::*; + + let mut bytes = ArchiveHeader::new(0x01, 1).to_bytes(); + bytes[0] = b'X'; // corrupt magic + assert!(ArchiveHeader::from_bytes(&bytes).is_err()); +} + +#[test] +fn test_archive_header_bad_version_rejected() { + use crate::core::evfs::archive::*; + + let mut bytes = ArchiveHeader::new(0x01, 1).to_bytes(); + bytes[4] = 99; // unsupported version + assert!(ArchiveHeader::from_bytes(&bytes).is_err()); +} + +#[test] +fn test_archive_trailer_roundtrip() { + use crate::core::evfs::archive::*; + + let checksum = [0xAB; 32]; + let trailer = ArchiveTrailer { checksum }; + let bytes = trailer.to_bytes(); + let parsed = ArchiveTrailer::from_bytes(&bytes).expect("parse"); + assert_eq!(parsed.checksum, checksum); +} + +#[test] +fn test_archive_trailer_bad_magic_rejected() { + use crate::core::evfs::archive::*; + + let mut bytes = (ArchiveTrailer { checksum: [0; 32] }).to_bytes(); + bytes[35] = b'Z'; // corrupt reverse magic + assert!(ArchiveTrailer::from_bytes(&bytes).is_err()); +} + +#[test] +fn test_segment_record_header_roundtrip() { + use crate::core::evfs::archive::*; + + let record = SegmentRecord { + name: "hello.txt".into(), + compression: 0x01, + checksum: [0xDD; 32], + encrypted_data: vec![0xFF; 100], + }; + let header = record.write_header().expect("write header"); + let (name, comp, cksum, data_len, consumed) = + SegmentRecord::read_header(&header).expect("read header"); + assert_eq!(name, "hello.txt"); + assert_eq!(comp, 0x01); + assert_eq!(cksum, [0xDD; 32]); + assert_eq!(data_len, 100); + assert_eq!(consumed, header.len()); +} + +#[test] +fn test_wrapped_key_size() { + use crate::core::evfs::archive::WRAPPED_KEY_SIZE; + // nonce(12) + ciphertext(32) + tag(16) = 60 + assert_eq!(WRAPPED_KEY_SIZE, 60); +} + +#[test] +fn test_archive_trailer_size() { + use crate::core::evfs::archive::ARCHIVE_TRAILER_SIZE; + // blake3(32) + reverse_magic(4) = 36 + assert_eq!(ARCHIVE_TRAILER_SIZE, 36); +} + +// -- Export integration tests --------------------------------------------- + +#[test] +fn test_export_produces_valid_archive() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (header, wrapped_key, records, _) = parse_archive(&epath); + assert_eq!(header.segment_count, 2); + assert_eq!(header.version, 1); + assert_eq!(records.len(), 2); + + // Decrypt and verify contents + let algo = crate::core::format::Algorithm::AesGcm; + for record in &records { + let plaintext = decrypt_record(record, &wrapped_key, &wrapping_key(), algo); + match record.name.as_str() { + "a.txt" => assert_eq!(plaintext, b"hello"), + "b.txt" => assert_eq!(plaintext, b"world"), + other => panic!("unexpected segment: {other}"), + } + // Verify BLAKE3 checksum in record matches plaintext + let expected = crate::core::evfs::segment::compute_checksum(&plaintext); + assert_eq!(record.checksum, expected); + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_empty_vault() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (header, _, records, _) = parse_archive(&epath); + assert_eq!(header.segment_count, 0); + assert!(records.is_empty()); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_with_streaming_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + // Write a streaming segment (> 64KB to trigger chunking) + let data: Vec = (0..200_000).map(|i| (i % 251) as u8).collect(); + let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); vault_write_stream( &mut handle, - "empty.bin".into(), - 0, + "big.bin".into(), + data.len() as u64, chunks.into_iter(), ) - .expect("write stream"); + .expect("stream write"); - let readback = vault_read(&mut handle, "empty.bin".into()).expect("read"); - assert!(readback.is_empty()); + // Also write a monolithic segment + vault_write(&mut handle, "small.txt".into(), b"tiny".to_vec(), None).expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (header, wrapped_key, records, _) = parse_archive(&epath); + assert_eq!(header.segment_count, 2); + + let algo = crate::core::format::Algorithm::AesGcm; + for record in &records { + let plaintext = decrypt_record(record, &wrapped_key, &wrapping_key(), algo); + match record.name.as_str() { + "big.bin" => assert_eq!(plaintext, data), + "small.txt" => assert_eq!(plaintext, b"tiny"), + other => panic!("unexpected segment: {other}"), + } + } + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_with_compressed_segments() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"compress me please ".repeat(100); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write( + &mut handle, + "compressed.txt".into(), + data.clone(), + Some(config), + ) + .expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (_, wrapped_key, records, _) = parse_archive(&epath); + assert_eq!(records.len(), 1); + + let algo = crate::core::format::Algorithm::AesGcm; + let plaintext = decrypt_record(&records[0], &wrapped_key, &wrapping_key(), algo); + assert_eq!(plaintext, data); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_does_not_modify_vault() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"data-A".to_vec(), None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"data-B".to_vec(), None).expect("write"); + + // Snapshot state before export + let names_before: Vec = vault_list(&handle).into_iter().collect(); + let health_before = vault_health(&handle); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + // Verify vault unchanged + let names_after: Vec = vault_list(&handle).into_iter().collect(); + let health_after = vault_health(&handle); + assert_eq!(names_before, names_after); + assert_eq!(health_before, health_after); + + // Verify data still readable + let a = vault_read(&mut handle, "a.txt".into()).expect("read a"); + assert_eq!(a, b"data-A"); + let b = vault_read(&mut handle, "b.txt".into()).expect("read b"); + assert_eq!(b, b"data-B"); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_wrong_wrapping_key_cannot_decrypt() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write( + &mut handle, + "secret.txt".into(), + b"top secret".to_vec(), + None, + ) + .expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + + let (_, wrapped_key, _records, _) = parse_archive(&epath); + let algo = crate::core::format::Algorithm::AesGcm; + + // Try to unwrap with wrong key + let wrong_wk = vec![0xEE; 32]; + let result = crate::core::evfs::segment::aead_decrypt_with_stored_nonce( + &wrong_wk, + &wrapped_key, + crate::core::evfs::archive::KEY_WRAP_AAD, + algo, + ); + assert!( + result.is_err(), + "wrong wrapping key must fail to unwrap export key" + ); + + vault_close(handle).expect("close"); +} + +#[test] +fn test_export_invalid_wrapping_key_length_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let epath = export_path(&dir); + let result = vault_export(&mut handle, vec![0xAA; 16], epath); // 16 bytes, not 32 + assert!(result.is_err()); + + vault_close(handle).expect("close"); +} + +// -- Import integration tests --------------------------------------------- + +fn import_dest_path(dir: &tempfile::TempDir) -> String { + dir.path() + .join("imported.vault") + .to_str() + .expect("path") + .to_string() +} + +#[test] +fn test_import_full_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 2_097_152); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + vault_write(&mut handle, "b.txt".into(), b"world".to_vec(), None).expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 2_097_152, + ) + .expect("import"); + + assert_eq!( + vault_read(&mut imported, "a.txt".into()).expect("read a"), + b"hello" + ); + assert_eq!( + vault_read(&mut imported, "b.txt".into()).expect("read b"), + b"world" + ); + + vault_close(imported).expect("close"); +} + +#[test] +fn test_import_wrong_wrapping_key_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let wrong_wk = vec![0xEE; 32]; + let result = vault_import( + epath, + wrong_wk, + dest_path, + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); +} + +#[test] +fn test_import_truncated_archive() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + // Truncate the archive midway + let mut archive = std::fs::read(&epath).expect("read"); + archive.truncate(archive.len() - 10); + std::fs::write(&epath, archive).expect("write"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); + assert!( + !std::path::Path::new(&dest_path).exists(), + "Partial vault must be deleted" + ); +} + +#[test] +fn test_import_tampered_segment_data() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + // Tamper with the encrypted data part of the segment record + let mut archive = std::fs::read(&epath).expect("read"); + let pos = archive.len() - 36 - 10; // slightly before the trailer + archive[pos] ^= 0xFF; + std::fs::write(&epath, archive).expect("write"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); +} + +#[test] +fn test_import_tampered_trailer_checksum() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + vault_write(&mut handle, "a.txt".into(), b"hello".to_vec(), None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + // Tamper with the trailer checksum + let mut archive = std::fs::read(&epath).expect("read"); + let pos = archive.len() - 36 + 5; // within the 32 byte checksum + archive[pos] ^= 0xFF; + std::fs::write(&epath, archive).expect("write"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ); + + assert!(matches!(result, Err(CryptoError::ImportFailed(_)))); +} + +#[test] +fn test_import_insufficient_capacity() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 5_000_000); + vault_write(&mut handle, "big.txt".into(), vec![0xBB; 2_000_000], None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let result = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, // Bound restriction triggers EVFS index allocation rejection + ); + + assert!(matches!(result, Err(CryptoError::VaultFull { .. }))); +} + +#[test] +fn test_import_streaming_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 4_194_304); + + let data: Vec = (0..200_000).map(|i| (i % 251) as u8).collect(); + let chunks: Vec> = data.chunks(65536).map(|c| c.to_vec()).collect(); + vault_write_stream( + &mut handle, + "stream.bin".into(), + data.len() as u64, + chunks.into_iter(), + ) + .expect("stream write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 4_194_304, + ) + .expect("import"); + + let readback = vault_read(&mut imported, "stream.bin".into()).expect("read"); + assert_eq!(readback, data); + + vault_close(imported).expect("close"); +} + +#[test] +fn test_import_compressed_segment() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = create_test_vault(&dir, 1_048_576); + + let data = b"compress me please ".repeat(100); + let config = CompressionConfig { + algorithm: CompressionAlgorithm::Zstd, + level: None, + }; + vault_write( + &mut handle, + "compressed.txt".into(), + data.clone(), + Some(config), + ) + .expect("write"); + + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "aes-256-gcm".into(), + 1_048_576, + ) + .expect("import"); + + let entry = imported.index.find("compressed.txt").expect("find"); + assert_eq!(entry.compression, CompressionAlgorithm::Zstd); + + let readback = vault_read(&mut imported, "compressed.txt".into()).expect("read"); + assert_eq!(readback, data); + + vault_close(imported).expect("close"); +} + +#[test] +fn test_import_chacha20() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut handle = vault_create( + vault_path(&dir), + test_key(), + "chacha20-poly1305".into(), + 1_048_576, + ) + .expect("create"); + + vault_write(&mut handle, "c.txt".into(), b"chacha".to_vec(), None).expect("write"); + let epath = export_path(&dir); + vault_export(&mut handle, wrapping_key(), epath.clone()).expect("export"); + vault_close(handle).expect("close"); + + let dest_path = import_dest_path(&dir); + let mut imported = vault_import( + epath, + wrapping_key(), + dest_path.clone(), + test_key2(), + "chacha20-poly1305".into(), + 1_048_576, + ) + .expect("import"); + + assert_eq!( + imported.algorithm, + crate::core::format::Algorithm::ChaCha20Poly1305 + ); + assert_eq!( + vault_read(&mut imported, "c.txt".into()).expect("read c"), + b"chacha" + ); + + vault_close(imported).expect("close"); } diff --git a/rust/src/api/evfs/types.rs b/rust/src/api/evfs/types.rs index 0961e6c..0a9d77f 100644 --- a/rust/src/api/evfs/types.rs +++ b/rust/src/api/evfs/types.rs @@ -55,9 +55,9 @@ impl VaultMmap { let size = usize::try_from(len).map_err(|_| { CryptoError::VaultCorrupted(format!("mmap length {len} exceeds address space")) })?; - let end = start.checked_add(size).ok_or_else(|| { - CryptoError::VaultCorrupted("mmap range overflow".into()) - })?; + let end = start + .checked_add(size) + .ok_or_else(|| CryptoError::VaultCorrupted("mmap range overflow".into()))?; if end > self.mmap.len() { return Err(CryptoError::VaultCorrupted(format!( "mmap read {start}..{end} exceeds file size {}", diff --git a/rust/src/core/error.rs b/rust/src/core/error.rs index 3c1a3c4..34b8585 100644 --- a/rust/src/core/error.rs +++ b/rust/src/core/error.rs @@ -46,6 +46,15 @@ pub enum CryptoError { #[error("Vault corrupted: {0}")] VaultCorrupted(String), + + #[error("Key rotation failed: {0}")] + KeyRotationFailed(String), + + #[error("Export failed: {0}")] + ExportFailed(String), + + #[error("Import failed: {0}")] + ImportFailed(String), } impl From for CryptoError { diff --git a/rust/src/core/evfs/archive.rs b/rust/src/core/evfs/archive.rs new file mode 100644 index 0000000..e7bb374 --- /dev/null +++ b/rust/src/core/evfs/archive.rs @@ -0,0 +1,195 @@ +//! `.mvex` portable encrypted archive format — types, constants, and serialization. + +use crate::core::error::CryptoError; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +pub const ARCHIVE_MAGIC: &[u8; 4] = b"MVEX"; +pub const REVERSE_MAGIC: &[u8; 4] = b"XEVM"; +pub const ARCHIVE_VERSION: u8 = 1; + +pub const ARCHIVE_HEADER_SIZE: usize = 32; +pub const WRAPPED_KEY_SIZE: usize = 60; // 12 nonce + 32 ciphertext + 16 tag +pub const ARCHIVE_TRAILER_SIZE: usize = 36; // 32 BLAKE3 + 4 reverse magic + +/// AAD used when wrapping the ephemeral export key with the caller's wrapping key. +pub const KEY_WRAP_AAD: &[u8] = b"msec-export-key-wrap"; + +// --------------------------------------------------------------------------- +// ArchiveHeader (32 bytes) +// --------------------------------------------------------------------------- + +/// Fixed-size header at the start of every `.mvex` archive. +/// +/// ```text +/// [0..4] magic "MVEX" +/// [4] version (1) +/// [5] algorithm byte (matches vault Algorithm enum) +/// [6..8] flags (reserved, LE u16) +/// [8..12] segment_count (LE u32) +/// [12..32] reserved (zeros) +/// ``` +pub struct ArchiveHeader { + pub version: u8, + pub algorithm: u8, + pub flags: u16, + pub segment_count: u32, +} + +impl ArchiveHeader { + pub fn new(algorithm: u8, segment_count: u32) -> Self { + Self { + version: ARCHIVE_VERSION, + algorithm, + flags: 0, + segment_count, + } + } + + pub fn to_bytes(&self) -> [u8; ARCHIVE_HEADER_SIZE] { + let mut buf = [0u8; ARCHIVE_HEADER_SIZE]; + buf[0..4].copy_from_slice(ARCHIVE_MAGIC); + buf[4] = self.version; + buf[5] = self.algorithm; + buf[6..8].copy_from_slice(&self.flags.to_le_bytes()); + buf[8..12].copy_from_slice(&self.segment_count.to_le_bytes()); + // [12..32] reserved zeros + buf + } + + pub fn from_bytes(buf: &[u8; ARCHIVE_HEADER_SIZE]) -> Result { + if &buf[0..4] != ARCHIVE_MAGIC { + return Err(CryptoError::ExportFailed("invalid archive magic".into())); + } + let version = buf[4]; + if version != ARCHIVE_VERSION { + return Err(CryptoError::ExportFailed(format!( + "unsupported archive version: {version}" + ))); + } + let algorithm = buf[5]; + let flags = u16::from_le_bytes([buf[6], buf[7]]); + let segment_count = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); + Ok(Self { + version, + algorithm, + flags, + segment_count, + }) + } +} + +// --------------------------------------------------------------------------- +// SegmentRecord — per-segment entry in the archive +// --------------------------------------------------------------------------- + +/// A single segment record in the archive. +/// +/// ```text +/// [name_len: u16 LE] [name: UTF-8 bytes] +/// [compression: u8] +/// [checksum: 32 bytes BLAKE3] +/// [data_len: u64 LE] (length of encrypted_data: nonce + ciphertext + tag) +/// [encrypted_data: data_len bytes] +/// ``` +pub struct SegmentRecord { + pub name: String, + pub compression: u8, + pub checksum: [u8; 32], + pub encrypted_data: Vec, +} + +impl SegmentRecord { + /// Serialize the record header (everything before encrypted_data) into bytes. + /// Returns the header bytes. The caller writes encrypted_data separately. + pub fn write_header(&self) -> Result, CryptoError> { + let name_bytes = self.name.as_bytes(); + let name_len = u16::try_from(name_bytes.len()).map_err(|_| { + CryptoError::ExportFailed("segment name too long for archive".into()) + })?; + let data_len = self.encrypted_data.len() as u64; + + // name_len(2) + name + compression(1) + checksum(32) + data_len(8) + let header_size = 2 + name_bytes.len() + 1 + 32 + 8; + let mut buf = Vec::with_capacity(header_size); + + buf.extend_from_slice(&name_len.to_le_bytes()); + buf.extend_from_slice(name_bytes); + buf.push(self.compression); + buf.extend_from_slice(&self.checksum); + buf.extend_from_slice(&data_len.to_le_bytes()); + + Ok(buf) + } + + /// Read a segment record header from a byte slice, returning (record_minus_data, bytes_consumed). + /// The caller must then read `data_len` bytes of encrypted_data. + pub fn read_header(data: &[u8]) -> Result<(String, u8, [u8; 32], u64, usize), CryptoError> { + if data.len() < 2 { + return Err(CryptoError::ExportFailed("truncated segment record".into())); + } + let name_len = u16::from_le_bytes([data[0], data[1]]) as usize; + let mut pos = 2; + + if data.len() < pos + name_len + 1 + 32 + 8 { + return Err(CryptoError::ExportFailed("truncated segment record".into())); + } + + let name = std::str::from_utf8(&data[pos..pos + name_len]) + .map_err(|_| CryptoError::ExportFailed("invalid UTF-8 segment name".into()))? + .to_string(); + pos += name_len; + + let compression = data[pos]; + pos += 1; + + let mut checksum = [0u8; 32]; + checksum.copy_from_slice(&data[pos..pos + 32]); + pos += 32; + + let data_len = u64::from_le_bytes( + data[pos..pos + 8] + .try_into() + .map_err(|_| CryptoError::ExportFailed("truncated data_len field".into()))?, + ); + pos += 8; + + Ok((name, compression, checksum, data_len, pos)) + } +} + +// --------------------------------------------------------------------------- +// ArchiveTrailer (36 bytes) +// --------------------------------------------------------------------------- + +/// Trailer at the end of every `.mvex` archive. +/// +/// ```text +/// [0..32] BLAKE3 hash of everything before the trailer +/// [32..36] reverse magic "XEVM" +/// ``` +pub struct ArchiveTrailer { + pub checksum: [u8; 32], +} + +impl ArchiveTrailer { + pub fn to_bytes(&self) -> [u8; ARCHIVE_TRAILER_SIZE] { + let mut buf = [0u8; ARCHIVE_TRAILER_SIZE]; + buf[0..32].copy_from_slice(&self.checksum); + buf[32..36].copy_from_slice(REVERSE_MAGIC); + buf + } + + pub fn from_bytes(buf: &[u8; ARCHIVE_TRAILER_SIZE]) -> Result { + if &buf[32..36] != REVERSE_MAGIC { + return Err(CryptoError::ExportFailed( + "invalid archive trailer magic".into(), + )); + } + let mut checksum = [0u8; 32]; + checksum.copy_from_slice(&buf[0..32]); + Ok(Self { checksum }) + } +} diff --git a/rust/src/core/evfs/mod.rs b/rust/src/core/evfs/mod.rs index b37f6b9..d2b9d2a 100644 --- a/rust/src/core/evfs/mod.rs +++ b/rust/src/core/evfs/mod.rs @@ -4,6 +4,7 @@ //! does not scan them. Only the public vault API in `api/evfs/` is //! exposed to Flutter. +pub mod archive; pub mod format; pub mod segment; pub mod wal; diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 6e9658d..8aae2e6 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -40,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueNom, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 2084471439; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1455605677; // Section: executor @@ -1290,6 +1290,53 @@ fn wire__crate__api__evfs__vault_delete_impl( }, ) } +fn wire__crate__api__evfs__vault_export_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: impl CstDecode< + RustOpaqueNom>, + >, + wrapping_key: impl CstDecode>, + export_path: impl CstDecode, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_export", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_handle = handle.cst_decode(); + let api_wrapping_key = wrapping_key.cst_decode(); + let api_export_path = export_path.cst_decode(); + move |context| { + transform_result_dco::<_, _, crate::core::error::CryptoError>((move || { + let mut api_handle_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_handle, + 0, + true, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_handle_guard = Some(api_handle.lockable_decode_sync_ref_mut()), + _ => unreachable!(), + } + } + let mut api_handle_guard = api_handle_guard.unwrap(); + let output_ok = crate::api::evfs::vault_export( + &mut *api_handle_guard, + api_wrapping_key, + api_export_path, + )?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__evfs__vault_health_impl( port_: flutter_rust_bridge::for_generated::MessagePort, handle: impl CstDecode< @@ -1330,6 +1377,44 @@ fn wire__crate__api__evfs__vault_health_impl( }, ) } +fn wire__crate__api__evfs__vault_import_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + archive_path: impl CstDecode, + wrapping_key: impl CstDecode>, + dest_path: impl CstDecode, + new_master_key: impl CstDecode>, + algorithm: impl CstDecode, + capacity_bytes: impl CstDecode, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_import", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_archive_path = archive_path.cst_decode(); + let api_wrapping_key = wrapping_key.cst_decode(); + let api_dest_path = dest_path.cst_decode(); + let api_new_master_key = new_master_key.cst_decode(); + let api_algorithm = algorithm.cst_decode(); + let api_capacity_bytes = capacity_bytes.cst_decode(); + move |context| { + transform_result_dco::<_, _, crate::core::error::CryptoError>((move || { + let output_ok = crate::api::evfs::vault_import( + api_archive_path, + api_wrapping_key, + api_dest_path, + api_new_master_key, + api_algorithm, + api_capacity_bytes, + )?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__evfs__vault_list_impl( port_: flutter_rust_bridge::for_generated::MessagePort, handle: impl CstDecode< @@ -1529,6 +1614,29 @@ fn wire__crate__api__evfs__vault_resize_impl( }, ) } +fn wire__crate__api__evfs__vault_rotate_key_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: impl CstDecode, + new_key: impl CstDecode>, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vault_rotate_key", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_handle = handle.cst_decode(); + let api_new_key = new_key.cst_decode(); + move |context| { + transform_result_dco::<_, _, crate::core::error::CryptoError>((move || { + let output_ok = crate::api::evfs::vault_rotate_key(api_handle, api_new_key)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__evfs__vault_write_impl( port_: flutter_rust_bridge::for_generated::MessagePort, handle: impl CstDecode< @@ -1897,6 +2005,18 @@ impl SseDecode for crate::core::error::CryptoError { let mut var_field0 = ::sse_decode(deserializer); return crate::core::error::CryptoError::VaultCorrupted(var_field0); } + 14 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::core::error::CryptoError::KeyRotationFailed(var_field0); + } + 15 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::core::error::CryptoError::ExportFailed(var_field0); + } + 16 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::core::error::CryptoError::ImportFailed(var_field0); + } _ => { unimplemented!(""); } @@ -2248,6 +2368,15 @@ impl flutter_rust_bridge::IntoDart for crate::core::error::CryptoError { crate::core::error::CryptoError::VaultCorrupted(field0) => { [13.into_dart(), field0.into_into_dart().into_dart()].into_dart() } + crate::core::error::CryptoError::KeyRotationFailed(field0) => { + [14.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } + crate::core::error::CryptoError::ExportFailed(field0) => { + [15.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } + crate::core::error::CryptoError::ImportFailed(field0) => { + [16.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } _ => { unimplemented!(""); } @@ -2528,6 +2657,18 @@ impl SseEncode for crate::core::error::CryptoError { ::sse_encode(13, serializer); ::sse_encode(field0, serializer); } + crate::core::error::CryptoError::KeyRotationFailed(field0) => { + ::sse_encode(14, serializer); + ::sse_encode(field0, serializer); + } + crate::core::error::CryptoError::ExportFailed(field0) => { + ::sse_encode(15, serializer); + ::sse_encode(field0, serializer); + } + crate::core::error::CryptoError::ImportFailed(field0) => { + ::sse_encode(16, serializer); + ::sse_encode(field0, serializer); + } _ => { unimplemented!(""); } @@ -2875,6 +3016,18 @@ mod io { let ans = unsafe { self.kind.VaultCorrupted }; crate::core::error::CryptoError::VaultCorrupted(ans.field0.cst_decode()) } + 14 => { + let ans = unsafe { self.kind.KeyRotationFailed }; + crate::core::error::CryptoError::KeyRotationFailed(ans.field0.cst_decode()) + } + 15 => { + let ans = unsafe { self.kind.ExportFailed }; + crate::core::error::CryptoError::ExportFailed(ans.field0.cst_decode()) + } + 16 => { + let ans = unsafe { self.kind.ImportFailed }; + crate::core::error::CryptoError::ImportFailed(ans.field0.cst_decode()) + } _ => unreachable!(), } } @@ -3392,6 +3545,16 @@ mod io { wire__crate__api__evfs__vault_delete_impl(port_, handle, name) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_export( + port_: i64, + handle: usize, + wrapping_key: *mut wire_cst_list_prim_u_8_loose, + export_path: *mut wire_cst_list_prim_u_8_strict, + ) { + wire__crate__api__evfs__vault_export_impl(port_, handle, wrapping_key, export_path) + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_health( port_: i64, @@ -3400,6 +3563,27 @@ mod io { wire__crate__api__evfs__vault_health_impl(port_, handle) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_import( + port_: i64, + archive_path: *mut wire_cst_list_prim_u_8_strict, + wrapping_key: *mut wire_cst_list_prim_u_8_loose, + dest_path: *mut wire_cst_list_prim_u_8_strict, + new_master_key: *mut wire_cst_list_prim_u_8_loose, + algorithm: *mut wire_cst_list_prim_u_8_strict, + capacity_bytes: u64, + ) { + wire__crate__api__evfs__vault_import_impl( + port_, + archive_path, + wrapping_key, + dest_path, + new_master_key, + algorithm, + capacity_bytes, + ) + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_list( port_: i64, @@ -3454,6 +3638,15 @@ mod io { wire__crate__api__evfs__vault_resize_impl(port_, handle, new_capacity) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_rotate_key( + port_: i64, + handle: usize, + new_key: *mut wire_cst_list_prim_u_8_loose, + ) { + wire__crate__api__evfs__vault_rotate_key_impl(port_, handle, new_key) + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_m_security_wire__crate__api__evfs__vault_write( port_: i64, @@ -3602,6 +3795,9 @@ mod io { VaultFull: wire_cst_CryptoError_VaultFull, SegmentNotFound: wire_cst_CryptoError_SegmentNotFound, VaultCorrupted: wire_cst_CryptoError_VaultCorrupted, + KeyRotationFailed: wire_cst_CryptoError_KeyRotationFailed, + ExportFailed: wire_cst_CryptoError_ExportFailed, + ImportFailed: wire_cst_CryptoError_ImportFailed, nil__: (), } #[repr(C)] @@ -3658,6 +3854,21 @@ mod io { } #[repr(C)] #[derive(Clone, Copy)] + pub struct wire_cst_CryptoError_KeyRotationFailed { + field0: *mut wire_cst_list_prim_u_8_strict, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct wire_cst_CryptoError_ExportFailed { + field0: *mut wire_cst_list_prim_u_8_strict, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct wire_cst_CryptoError_ImportFailed { + field0: *mut wire_cst_list_prim_u_8_strict, + } + #[repr(C)] + #[derive(Clone, Copy)] pub struct wire_cst_defrag_result { segments_moved: u32, bytes_reclaimed: u64, @@ -3804,6 +4015,9 @@ mod web { 11 => crate::core::error::CryptoError::VaultLocked, 12 => crate::core::error::CryptoError::SegmentNotFound(self_.get(1).cst_decode()), 13 => crate::core::error::CryptoError::VaultCorrupted(self_.get(1).cst_decode()), + 14 => crate::core::error::CryptoError::KeyRotationFailed(self_.get(1).cst_decode()), + 15 => crate::core::error::CryptoError::ExportFailed(self_.get(1).cst_decode()), + 16 => crate::core::error::CryptoError::ImportFailed(self_.get(1).cst_decode()), _ => unreachable!(), } } @@ -4457,6 +4671,16 @@ mod web { wire__crate__api__evfs__vault_delete_impl(port_, handle, name) } + #[wasm_bindgen] + pub fn wire__crate__api__evfs__vault_export( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + wrapping_key: Box<[u8]>, + export_path: String, + ) { + wire__crate__api__evfs__vault_export_impl(port_, handle, wrapping_key, export_path) + } + #[wasm_bindgen] pub fn wire__crate__api__evfs__vault_health( port_: flutter_rust_bridge::for_generated::MessagePort, @@ -4465,6 +4689,27 @@ mod web { wire__crate__api__evfs__vault_health_impl(port_, handle) } + #[wasm_bindgen] + pub fn wire__crate__api__evfs__vault_import( + port_: flutter_rust_bridge::for_generated::MessagePort, + archive_path: String, + wrapping_key: Box<[u8]>, + dest_path: String, + new_master_key: Box<[u8]>, + algorithm: String, + capacity_bytes: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + ) { + wire__crate__api__evfs__vault_import_impl( + port_, + archive_path, + wrapping_key, + dest_path, + new_master_key, + algorithm, + capacity_bytes, + ) + } + #[wasm_bindgen] pub fn wire__crate__api__evfs__vault_list( port_: flutter_rust_bridge::for_generated::MessagePort, @@ -4519,6 +4764,15 @@ mod web { wire__crate__api__evfs__vault_resize_impl(port_, handle, new_capacity) } + #[wasm_bindgen] + pub fn wire__crate__api__evfs__vault_rotate_key( + port_: flutter_rust_bridge::for_generated::MessagePort, + handle: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + new_key: Box<[u8]>, + ) { + wire__crate__api__evfs__vault_rotate_key_impl(port_, handle, new_key) + } + #[wasm_bindgen] pub fn wire__crate__api__evfs__vault_write( port_: flutter_rust_bridge::for_generated::MessagePort,