diff --git a/docs/releases/v1.6.1.115.md b/docs/releases/v1.6.1.115.md new file mode 100644 index 000000000..da067f74f --- /dev/null +++ b/docs/releases/v1.6.1.115.md @@ -0,0 +1,29 @@ +# 🎉 Submersion v1.6.1.115 Release Notes + +This release adds optional end-to-end encryption for cloud sync and cloud backups. Submersion's sync has always used storage you control — iCloud or an S3-compatible bucket — with no Submersion server in the middle, but until now your storage provider could still read what you uploaded: dive sites and their GPS coordinates, buddy names, certifications, and notes. Turn encryption on and Submersion encrypts everything it writes to the cloud on your device first, so the provider only ever sees opaque blobs. + +--- + +## ✨ New and improved + +### Optional end-to-end encryption for cloud sync and backups + +A new **End-to-end encryption** section under Settings → Cloud Sync lets you lock your cloud library behind a passphrase that never leaves your devices. You set it up on one device: choose a passphrase, save the eight-word recovery code Submersion shows you, and it re-uploads your whole library in encrypted form. From then on every sync — and every backup uploaded to your cloud storage — is encrypted before it leaves the device, using AES-256 with a key derived from your passphrase. Your storage provider, or anyone who gets hold of your storage credentials, sees only unreadable files. + +Encryption is opt-in and off by default, and it protects the contents of your files rather than their existence: file names, sizes, and timestamps remain visible to the provider, so a private bucket and a tightly scoped access token are still worth keeping. The dive log on each of your devices is never encrypted by this feature, so you never type a passphrase just to open the app or read your own dives — only what leaves the device for the cloud is protected. + +Each device is unlocked once. When another device syncs to an encrypted library, sync pauses and the Cloud Sync page shows an "Enter passphrase" banner; enter the passphrase (or the recovery code) and that device unlocks the library, downloads it, and syncs normally from then on, remembering the key in its secure keychain. You can change your passphrase at any time without re-uploading your library, generate a fresh recovery code if you misplace the old one, or turn encryption back off to return to plaintext sync. Because Submersion never stores the recovery code itself, it cannot show it to you later — if it is lost, you generate a new one. + +Turning encryption on republishes the cloud library in a form older app versions cannot read, so update Submersion on all of your devices to this version before — or soon after — enabling it. A device left on an older version simply stops syncing with an error rather than losing or damaging anything, until you update it. + +Losing both your passphrase and recovery code does not mean losing your dives. Because the library on each device is never encrypted, any device that already has your data still has all of it; you re-publish from that device (turning encryption off, or on again with a new passphrase) and your other devices adopt the new library. The only thing lost is the old cloud copy, which was unreadable anyway. + +### Encrypted cloud backups + +Cloud backups are covered by the same setting. While encryption is on, each backup uploaded to your cloud storage is encrypted with your passphrase and is self-contained: you can restore it on a brand-new device by entering the passphrase or recovery code, even before sync is configured there, and Submersion prompts for it automatically when the backup it is opening is encrypted. Backups you save to a local folder, a connected drive, or the share sheet stay unencrypted on purpose — a backup is your safety net and should never be locked behind a passphrase you might forget. + +--- + +## 🔧 Under the hood + +Encryption is implemented as a layer around the existing storage backends rather than a change to the sync protocol, so every sync file becomes ciphertext by construction and the merge logic is untouched. It uses Argon2id to derive keys from your passphrase and recovery code, HKDF and AES-256-GCM (which detects tampering as well as preventing reading) for the data itself, and a wrapped-key design so that changing your passphrase re-wraps the library key instantly instead of re-encrypting your whole logbook. Enabling and disabling encryption reuse the same library-replacement mechanism that a "Replace everywhere" restore already uses, which is why other devices adopt the change the same familiar way. No database migration is required, and all new interface text is translated into all ten supported languages. diff --git a/docs/superpowers/plans/2026-07-10-encrypted-cloud-sync.md b/docs/superpowers/plans/2026-07-10-encrypted-cloud-sync.md new file mode 100644 index 000000000..65b809d3f --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-encrypted-cloud-sync.md @@ -0,0 +1,2476 @@ +# Encrypted Cloud Sync and Cloud Backups (#520) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Opt-in end-to-end encryption for all cloud sync files and cloud backups, per the approved spec `docs/superpowers/specs/2026-07-10-encrypted-cloud-sync-design.md`. + +**Architecture:** A random 256-bit master library key (MLK) is wrapped by Argon2id-derived keys (passphrase slot + recovery-code slot) in a plaintext keyslot file; an `EncryptingCloudStorageProvider` decorator seals every other cloud byte in an `SBE1` AES-256-GCM envelope (AAD = filename, optional gzip). Enable/disable rides the existing library-replace (epoch) machinery; a new `awaitingPassphrase` sync status drives the join flow. Cloud backups use a framed variant with embedded keyslots so each artifact is self-decrypting. + +**Tech Stack:** Flutter/Dart, `cryptography` 2.9.0 + `cryptography_flutter` (already added to pubspec on this branch), `flutter_secure_storage` via existing `FallbackSecureStorage`, Drift (no schema change), Riverpod, SharedPreferences (`SyncPreferences`). + +## Global Constraints + +- Work happens in worktree `.claude/worktrees/520-encrypted-sync`, branch `worktree-520-encrypted-sync`. Never touch the main checkout. +- The spec is the authority: `docs/superpowers/specs/2026-07-10-encrypted-cloud-sync-design.md`. Read it before starting any task. +- TDD every task: failing test first, then minimal implementation. +- All crypto test vectors are generated by the python3 script in Task 1 and committed as fixtures. NEVER write expected ciphertexts/hashes from memory. +- No emojis anywhere. `dart format .` (whole repo) must be clean after every task. `flutter analyze` (whole project, no pipes that mask failures) must be clean after every task. +- Run targeted test files, not the whole suite (whole suite is slow): `flutter test [...]`. +- New user-facing strings: add to `lib/l10n/arb/app_en.arb` when introduced; Task 14 translates into ALL 10 other locales (ar, de, es, fr, he, hu, it, nl, pt, zh) and regenerates. UI code always reads strings through `AppLocalizations` (`context.l10n` if that extension is used in neighboring code — match the file you are editing). +- Commit at the end of every task with the exact message given. No Co-Authored-By or other trailers. +- Argon2id `memory` parameter is in 1-KiB blocks in package:cryptography (65536 = 64 MiB). The KAT vectors in Task 1/2 exist to catch any unit mismatch — if a vector fails, check units before touching the vector. +- Wire-format constants are frozen by the spec: magic `SBE1`, single-shot header = magic(4) | keyId(16) | flags(1) | nonce(12); framed header = magic(4) | keyId(16) | flags(1) | uint32 slotLen | slot JSON; flags bit0 = gzip, bit1 = framed. AAD single-shot = UTF-8 filename. AAD per frame = keyId(16) || uint64 BE frameIndex || finalFlag(1). + +--- + +### Task 1: Crypto errors + SBE1 single-shot envelope + python KAT vectors + +**Files:** +- Create: `scripts/generate_crypto_test_vectors.py` +- Create: `test/fixtures/crypto/crypto_vectors.json` (generated, committed) +- Create: `lib/core/services/sync/crypto/crypto_errors.dart` +- Create: `lib/core/services/sync/crypto/sync_envelope.dart` +- Test: `test/core/services/sync/crypto/sync_envelope_test.dart` +- Modify: `pubspec.yaml` / `pubspec.lock` (already contain `cryptography: ^2.9.0`, `cryptography_flutter: ^2.3.4` — just commit them here) + +**Interfaces:** +- Consumes: `package:cryptography` (`AesGcm.with256bits()`, `SecretKey`, `SecretBox`). +- Produces (used by Tasks 2, 5, 6, 9): + - `class SyncEncryptionRequired implements Exception { final String? libraryKeyId; final String message; }` + - `class EnvelopeCorruptException implements Exception { final String message; }` + - `abstract final class SyncEnvelope` with: + - `static const List magic` (`[0x53, 0x42, 0x45, 0x31]`) + - `static bool hasMagic(List bytes)` + - `static String libraryKeyIdOf(Uint8List bytes)` (UUID string; throws `EnvelopeCorruptException` when too short/wrong magic) + - `static Future seal({required Uint8List plaintext, required SecretKey dataKey, required String libraryKeyId, required String filename, bool compress = true, List? nonceForTest})` + - `static Future open({required Uint8List envelope, required SecretKey dataKey, required String expectedLibraryKeyId, required String filename})` (throws `SyncEncryptionRequired` on keyId mismatch, `EnvelopeCorruptException` on bad magic/short/auth failure) + +- [ ] **Step 1: Write the vector generator script** + +Create `scripts/generate_crypto_test_vectors.py` (run with python3; needs `pip install cryptography argon2-cffi` if missing): + +```python +#!/usr/bin/env python3 +"""Generates known-answer test vectors for Submersion's SBE1 crypto. + +Output: test/fixtures/crypto/crypto_vectors.json +All values are base64 unless noted. Deterministic (fixed inputs). +""" +import base64 +import gzip +import json +import struct +import uuid + +from argon2.low_level import Type, hash_secret_raw +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes + + +def b64(b: bytes) -> str: + return base64.b64encode(b).decode() + + +KEY = bytes(range(32)) # 000102...1f +NONCE = bytes(range(12)) # 000102...0b +KEY_ID = uuid.UUID("8f14e45f-ceea-467f-ab37-a10a8d5f4c11") +FILENAME = "ssv1.devA.cs.00042.json" +PLAINTEXT = b'{"hello":"submersion","n":42}' + +# --- raw AES-256-GCM --- +gcm = AESGCM(KEY) +ct = gcm.encrypt(NONCE, PLAINTEXT, FILENAME.encode()) # ct||tag + +# --- HKDF-SHA256 (data key derivation) --- +hkdf_out = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, + info=b"sbe:v1:data").derive(KEY) + +# --- Argon2id (KDF for keyslots) --- small params so tests stay fast +ARGON_SALT = bytes(range(16)) +argon_out = hash_secret_raw( + secret=b"correct horse battery staple", salt=ARGON_SALT, + time_cost=3, memory_cost=1024, parallelism=1, hash_len=32, + type=Type.ID) + +# --- full SBE1 single-shot envelope (gzip off), built byte-for-byte --- +header = b"SBE1" + KEY_ID.bytes + bytes([0]) + NONCE +env_plain = header + ct + +# --- full SBE1 single-shot envelope (gzip on) --- +gz = gzip.compress(PLAINTEXT, mtime=0) +ct_gz = AESGCM(KEY).encrypt(NONCE, gz, FILENAME.encode()) +env_gzip = b"SBE1" + KEY_ID.bytes + bytes([1]) + NONCE + ct_gz + +vectors = { + "aesGcm": {"key": b64(KEY), "nonce": b64(NONCE), + "aad": FILENAME, "plaintext": b64(PLAINTEXT), + "ciphertextWithTag": b64(ct)}, + "hkdfData": {"ikm": b64(KEY), "info": "sbe:v1:data", + "output": b64(hkdf_out)}, + "argon2id": {"password": "correct horse battery staple", + "salt": b64(ARGON_SALT), "m": 1024, "t": 3, "p": 1, + "output": b64(argon_out)}, + "envelopePlain": {"key": b64(KEY), "keyId": str(KEY_ID), + "filename": FILENAME, "plaintext": b64(PLAINTEXT), + "envelope": b64(env_plain)}, + "envelopeGzip": {"key": b64(KEY), "keyId": str(KEY_ID), + "filename": FILENAME, "plaintext": b64(PLAINTEXT), + "envelope": b64(env_gzip)}, +} +with open("test/fixtures/crypto/crypto_vectors.json", "w") as f: + json.dump(vectors, f, indent=2) +print("wrote test/fixtures/crypto/crypto_vectors.json") +``` + +- [ ] **Step 2: Generate the fixture** + +Run from the worktree root: + +```bash +mkdir -p test/fixtures/crypto +python3 scripts/generate_crypto_test_vectors.py +``` + +Expected: `wrote test/fixtures/crypto/crypto_vectors.json`. If python modules are missing: `python3 -m pip install cryptography argon2-cffi` and re-run. Inspect the JSON — every field non-empty. + +NOTE on gzip determinism: the Dart test only asserts DECRYPTION of `envelopeGzip` (python gzip bytes may differ from Dart's), and separately asserts a Dart seal->open round trip with compress on. Never compare compressed bytes across languages. + +- [ ] **Step 3: Write the failing tests** + +Create `test/core/services/sync/crypto/sync_envelope_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + +Map _vectors() => jsonDecode( + File('test/fixtures/crypto/crypto_vectors.json').readAsStringSync(), +) as Map; + +Uint8List _b64(Map m, String k) => + base64Decode(m[k] as String); + +void main() { + final v = _vectors(); + + group('SyncEnvelope', () { + test('opens the python-built plain envelope (KAT)', () async { + final c = v['envelopePlain'] as Map; + final out = await SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + ); + expect(out, _b64(c, 'plaintext')); + }); + + test('opens the python-built gzip envelope (KAT)', () async { + final c = v['envelopeGzip'] as Map; + final out = await SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + ); + expect(out, _b64(c, 'plaintext')); + }); + + test('seal produces byte-exact envelope with injected nonce (KAT)', + () async { + final c = v['envelopePlain'] as Map; + final aes = v['aesGcm'] as Map; + final sealed = await SyncEnvelope.seal( + plaintext: _b64(c, 'plaintext'), + dataKey: SecretKey(_b64(c, 'key')), + libraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + compress: false, + nonceForTest: _b64(aes, 'nonce'), + ); + expect(sealed, _b64(c, 'envelope')); + }); + + test('round-trips with compression and random nonce', () async { + final key = SecretKey(List.generate(32, (i) => 255 - i)); + final plain = Uint8List.fromList( + utf8.encode('{"repeat":"${'ab' * 4000}"}'), + ); + final sealed = await SyncEnvelope.seal( + plaintext: plain, + dataKey: key, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + filename: 'f.json', + ); + expect(sealed.length, lessThan(plain.length)); // gzip effective + final opened = await SyncEnvelope.open( + envelope: sealed, + dataKey: key, + expectedLibraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + filename: 'f.json', + ); + expect(opened, plain); + }); + + test('wrong filename (AAD) fails authentication', () async { + final c = v['envelopePlain'] as Map; + await expectLater( + SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: 'ssv1.devB.manifest.json', + ), + throwsA(isA()), + ); + }); + + test('bit flip in ciphertext fails authentication', () async { + final c = v['envelopePlain'] as Map; + final tampered = Uint8List.fromList(_b64(c, 'envelope')); + tampered[tampered.length - 1] ^= 0x01; + await expectLater( + SyncEnvelope.open( + envelope: tampered, + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + ), + throwsA(isA()), + ); + }); + + test('keyId mismatch throws SyncEncryptionRequired with the keyId', + () async { + final c = v['envelopePlain'] as Map; + await expectLater( + SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: '00000000-0000-0000-0000-000000000000', + filename: c['filename'] as String, + ), + throwsA( + isA().having( + (e) => e.libraryKeyId, + 'libraryKeyId', + c['keyId'] as String, + ), + ), + ); + }); + + test('hasMagic and libraryKeyIdOf', () { + final c = v['envelopePlain'] as Map; + final env = _b64(c, 'envelope'); + expect(SyncEnvelope.hasMagic(env), isTrue); + expect(SyncEnvelope.hasMagic(utf8.encode('{"json":1}')), isFalse); + expect(SyncEnvelope.libraryKeyIdOf(env), c['keyId'] as String); + expect( + () => SyncEnvelope.libraryKeyIdOf(Uint8List.fromList([1, 2, 3])), + throwsA(isA()), + ); + }); + }); +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `flutter test test/core/services/sync/crypto/sync_envelope_test.dart` +Expected: FAIL — imports of `crypto_errors.dart` / `sync_envelope.dart` do not resolve. + +- [ ] **Step 5: Implement errors + envelope** + +Create `lib/core/services/sync/crypto/crypto_errors.dart`: + +```dart +/// The cloud library is encrypted and this device holds no (matching) key. +/// +/// Carriers of this exception halt sync with +/// SyncResultStatus.awaitingPassphrase instead of surfacing a raw error. +class SyncEncryptionRequired implements Exception { + final String? libraryKeyId; + final String message; + + const SyncEncryptionRequired({ + this.libraryKeyId, + this.message = 'The cloud library is encrypted', + }); + + @override + String toString() => 'SyncEncryptionRequired($libraryKeyId): $message'; +} + +/// An SBE1 envelope failed structural parsing or authentication. Treated +/// like a checksum failure: transient-stop, never a silent fallback. +class EnvelopeCorruptException implements Exception { + final String message; + + const EnvelopeCorruptException(this.message); + + @override + String toString() => 'EnvelopeCorruptException: $message'; +} +``` + +Create `lib/core/services/sync/crypto/sync_envelope.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:uuid/uuid_value.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; + +/// Single-shot SBE1 envelope: the byte form of every encrypted sync file. +/// +/// Layout (spec 3.3): "SBE1"(4) | libraryKeyId(16) | flags(1) | nonce(12) +/// | AES-256-GCM ciphertext || 16-byte tag. AAD = UTF-8 logical filename. +/// flags bit0 = payload was gzipped before encryption. +abstract final class SyncEnvelope { + static const List magic = [0x53, 0x42, 0x45, 0x31]; // "SBE1" + static const int _headerLength = 4 + 16 + 1 + 12; + static const int _flagGzip = 0x01; + + static final AesGcm _aesGcm = AesGcm.with256bits(); + + static bool hasMagic(List bytes) => + bytes.length >= magic.length && + bytes[0] == magic[0] && + bytes[1] == magic[1] && + bytes[2] == magic[2] && + bytes[3] == magic[3]; + + /// The UUID string at header offset 4, without decrypting. + static String libraryKeyIdOf(Uint8List bytes) { + if (!hasMagic(bytes) || bytes.length < _headerLength) { + throw const EnvelopeCorruptException('Not an SBE1 envelope'); + } + return UuidValue.fromByteList(Uint8List.sublistView(bytes, 4, 20)).uuid; + } + + static Future seal({ + required Uint8List plaintext, + required SecretKey dataKey, + required String libraryKeyId, + required String filename, + bool compress = true, + List? nonceForTest, + }) async { + var payload = plaintext; + var flags = 0; + if (compress) { + final gz = Uint8List.fromList(gzip.encode(plaintext)); + if (gz.length < plaintext.length) { + payload = gz; + flags |= _flagGzip; + } + } + final nonce = nonceForTest ?? _aesGcm.newNonce(); + final box = await _aesGcm.encrypt( + payload, + secretKey: dataKey, + nonce: nonce, + aad: utf8.encode(filename), + ); + final keyIdBytes = UuidValue.withValidation(libraryKeyId).toBytes(); + final out = BytesBuilder(copy: false) + ..add(magic) + ..add(keyIdBytes) + ..addByte(flags) + ..add(box.nonce) + ..add(box.cipherText) + ..add(box.mac.bytes); + return out.takeBytes(); + } + + static Future open({ + required Uint8List envelope, + required SecretKey dataKey, + required String expectedLibraryKeyId, + required String filename, + }) async { + if (!hasMagic(envelope) || envelope.length < _headerLength + 16) { + throw const EnvelopeCorruptException('Not an SBE1 envelope'); + } + final keyId = libraryKeyIdOf(envelope); + if (keyId != expectedLibraryKeyId.toLowerCase()) { + throw SyncEncryptionRequired( + libraryKeyId: keyId, + message: 'File is encrypted under a different library key', + ); + } + final flags = envelope[20]; + final nonce = Uint8List.sublistView(envelope, 21, 21 + 12); + final body = Uint8List.sublistView(envelope, _headerLength); + final box = SecretBox( + Uint8List.sublistView(body, 0, body.length - 16), + nonce: nonce, + mac: Mac(Uint8List.sublistView(body, body.length - 16)), + ); + final List payload; + try { + payload = await _aesGcm.decrypt( + box, + secretKey: dataKey, + aad: utf8.encode(filename), + ); + } on SecretBoxAuthenticationError { + throw const EnvelopeCorruptException( + 'Envelope failed authentication (corrupt, tampered, or wrong name)', + ); + } + if ((flags & _flagGzip) != 0) { + return Uint8List.fromList(gzip.decode(payload)); + } + return Uint8List.fromList(payload); + } +} +``` + +Implementation notes: +- AAD is `utf8.encode(filename)` in BOTH seal and open (matches the python vector). Add `import 'dart:convert';`. +- If `UuidValue.fromByteList` / `toBytes` are not exposed under `package:uuid/uuid_value.dart` in the repo's uuid version, check `grep -rn "class UuidValue" ~/.pub-cache/hosted/pub.dev/uuid-*/lib/` and adjust the import; do not hand-roll hex conversion unless the package truly lacks it. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `flutter test test/core/services/sync/crypto/sync_envelope_test.dart` +Expected: all PASS. If the seal KAT fails byte-exact, diff header offsets first (magic/keyId/flags/nonce ordering per Global Constraints). + +- [ ] **Step 7: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add pubspec.yaml pubspec.lock scripts/generate_crypto_test_vectors.py \ + test/fixtures/crypto/crypto_vectors.json \ + lib/core/services/sync/crypto/ test/core/services/sync/crypto/ +git commit -m "feat(sync): SBE1 encrypted envelope with python-generated KAT vectors (#520)" +``` + +--- + +### Task 2: Keyslots — Argon2id KDF, wrap/unwrap, keyslot file + +**Files:** +- Create: `lib/core/services/sync/crypto/keyslots.dart` +- Test: `test/core/services/sync/crypto/keyslots_test.dart` + +**Interfaces:** +- Consumes: `SyncEnvelope` conventions are NOT used here (slots are their own tiny GCM blobs); consumes `package:cryptography` and Task 1 fixtures. +- Produces (used by Tasks 4, 5, 7, 9): + - `class KdfParams { final String alg; final int m; final int t; final int p; const KdfParams({this.alg = 'argon2id', this.m = 65536, this.t = 3, this.p = 1}); Map toJson(); factory KdfParams.fromJson(Map); }` + - `class Keyslot { final String type; final Uint8List salt; final KdfParams kdf; final Uint8List nonce; final Uint8List wrapped; ... toJson/fromJson }` — `type` is `'passphrase'` or `'recovery'`. + - `class KeyslotFile { static const String cloudFileName = 'submersion_keyslots.json'; final int version; final String libraryKeyId; final List slots; Uint8List toJsonBytes(); factory KeyslotFile.fromJsonBytes(Uint8List); KeyslotFile withReplacedSlot(Keyslot slot); }` — `withReplacedSlot` replaces the slot with the same `type` (or appends). + - `abstract final class Keyslots` with: + - `static Future deriveKek({required String secret, required Uint8List salt, required KdfParams kdf})` + - `static Future createSlot({required String type, required String secret, required SecretKey mlk, KdfParams kdf = const KdfParams(), Uint8List? saltForTest, List? nonceForTest})` + - `static Future tryUnwrap({required KeyslotFile file, required String secret})` — tries every slot, normalizing the secret with `RecoveryCode.normalize` for `'recovery'` slots (Task 3; until Task 3 lands, recovery normalization is identity — see Step 3 note); returns null when no slot authenticates. + - `static Future deriveDataKey(SecretKey mlk)` — HKDF-SHA256, `info = utf8.encode('sbe:v1:data')`, no salt, 32 bytes. + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/sync/crypto/keyslots_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; + +Map _vectors() => jsonDecode( + File('test/fixtures/crypto/crypto_vectors.json').readAsStringSync(), +) as Map; + +void main() { + final v = _vectors(); + // Small KDF params keep the suite fast; production defaults differ. + const fastKdf = KdfParams(m: 1024, t: 3, p: 1); + + group('Keyslots', () { + test('Argon2id derivation matches python vector (KAT)', () async { + final c = v['argon2id'] as Map; + final kek = await Keyslots.deriveKek( + secret: c['password'] as String, + salt: base64Decode(c['salt'] as String), + kdf: KdfParams(m: c['m'] as int, t: c['t'] as int, p: c['p'] as int), + ); + expect(await kek.extractBytes(), base64Decode(c['output'] as String)); + }); + + test('HKDF data key matches python vector (KAT)', () async { + final c = v['hkdfData'] as Map; + final dataKey = await Keyslots.deriveDataKey( + SecretKey(base64Decode(c['ikm'] as String)), + ); + expect( + await dataKey.extractBytes(), + base64Decode(c['output'] as String), + ); + }); + + test('create slot then unwrap returns the MLK', () async { + final mlk = SecretKey(List.generate(32, (i) => i * 7 % 256)); + final slot = await Keyslots.createSlot( + type: 'passphrase', + secret: 'open sesame 42', + mlk: mlk, + kdf: fastKdf, + ); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [slot], + ); + final unwrapped = + await Keyslots.tryUnwrap(file: file, secret: 'open sesame 42'); + expect(await unwrapped!.extractBytes(), await mlk.extractBytes()); + }); + + test('wrong secret returns null', () async { + final mlk = SecretKey(List.generate(32, (i) => i)); + final slot = await Keyslots.createSlot( + type: 'passphrase', + secret: 'right', + mlk: mlk, + kdf: fastKdf, + ); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [slot], + ); + expect(await Keyslots.tryUnwrap(file: file, secret: 'wrong'), isNull); + }); + + test('keyslot file JSON round-trips and matches spec shape', () async { + final mlk = SecretKey(List.generate(32, (i) => 32 - i)); + final p = await Keyslots.createSlot( + type: 'passphrase', secret: 'p', mlk: mlk, kdf: fastKdf); + final r = await Keyslots.createSlot( + type: 'recovery', secret: 'acid-acorn-acre-act-add-age-aid-aim', + mlk: mlk, kdf: fastKdf); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [p, r], + ); + final decoded = KeyslotFile.fromJsonBytes(file.toJsonBytes()); + expect(decoded.version, 1); + expect(decoded.libraryKeyId, file.libraryKeyId); + expect(decoded.slots.length, 2); + expect(decoded.slots.first.kdf.alg, 'argon2id'); + // and the decoded copy still unlocks: + final unwrapped = await Keyslots.tryUnwrap(file: decoded, secret: 'p'); + expect(await unwrapped!.extractBytes(), await mlk.extractBytes()); + }); + + test('withReplacedSlot swaps by type', () async { + final mlk = SecretKey(List.generate(32, (i) => i)); + final p1 = await Keyslots.createSlot( + type: 'passphrase', secret: 'one', mlk: mlk, kdf: fastKdf); + final p2 = await Keyslots.createSlot( + type: 'passphrase', secret: 'two', mlk: mlk, kdf: fastKdf); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [p1], + ).withReplacedSlot(p2); + expect(file.slots.length, 1); + expect(await Keyslots.tryUnwrap(file: file, secret: 'one'), isNull); + expect(await Keyslots.tryUnwrap(file: file, secret: 'two'), isNotNull); + }); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/core/services/sync/crypto/keyslots_test.dart` +Expected: FAIL — `keyslots.dart` does not exist. + +- [ ] **Step 3: Implement keyslots** + +Create `lib/core/services/sync/crypto/keyslots.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; + +/// Argon2id parameters, stored per slot so they can be raised later by +/// rewrapping. Package unit note: `m` is in 1-KiB blocks (65536 = 64 MiB). +class KdfParams { + final String alg; + final int m; + final int t; + final int p; + + const KdfParams({this.alg = 'argon2id', this.m = 65536, this.t = 3, this.p = 1}); + + Map toJson() => {'alg': alg, 'm': m, 't': t, 'p': p}; + + factory KdfParams.fromJson(Map json) => KdfParams( + alg: json['alg'] as String, + m: json['m'] as int, + t: json['t'] as int, + p: json['p'] as int, + ); +} + +/// One wrapped copy of the master library key. +class Keyslot { + final String type; // 'passphrase' | 'recovery' + final Uint8List salt; + final KdfParams kdf; + final Uint8List nonce; + final Uint8List wrapped; // AES-256-GCM(MLK) ciphertext||tag + + const Keyslot({ + required this.type, + required this.salt, + required this.kdf, + required this.nonce, + required this.wrapped, + }); + + Map toJson() => { + 'type': type, + 'salt': base64Encode(salt), + 'kdf': kdf.toJson(), + 'nonce': base64Encode(nonce), + 'wrapped': base64Encode(wrapped), + }; + + factory Keyslot.fromJson(Map json) => Keyslot( + type: json['type'] as String, + salt: base64Decode(json['salt'] as String), + kdf: KdfParams.fromJson(json['kdf'] as Map), + nonce: base64Decode(json['nonce'] as String), + wrapped: base64Decode(json['wrapped'] as String), + ); +} + +/// The one plaintext cloud file: wrapped keys plus their KDF parameters. +class KeyslotFile { + /// Must not contain the 'submersion_sync' discovery stem (same rule as + /// the epoch marker, see library_epoch.dart). + static const String cloudFileName = 'submersion_keyslots.json'; + + final int version; + final String libraryKeyId; + final List slots; + + const KeyslotFile({ + required this.version, + required this.libraryKeyId, + required this.slots, + }); + + Uint8List toJsonBytes() => Uint8List.fromList( + utf8.encode(jsonEncode({ + 'version': version, + 'libraryKeyId': libraryKeyId, + 'slots': [for (final s in slots) s.toJson()], + })), + ); + + factory KeyslotFile.fromJsonBytes(Uint8List bytes) { + final json = jsonDecode(utf8.decode(bytes)) as Map; + return KeyslotFile( + version: json['version'] as int, + libraryKeyId: json['libraryKeyId'] as String, + slots: [ + for (final s in json['slots'] as List) + Keyslot.fromJson(s as Map), + ], + ); + } + + /// Replace the slot sharing [slot]'s type, or append when absent. + KeyslotFile withReplacedSlot(Keyslot slot) => KeyslotFile( + version: version, + libraryKeyId: libraryKeyId, + slots: [ + for (final s in slots) + if (s.type != slot.type) s, + slot, + ], + ); +} + +abstract final class Keyslots { + static final AesGcm _aesGcm = AesGcm.with256bits(); + + static Future deriveKek({ + required String secret, + required Uint8List salt, + required KdfParams kdf, + }) { + final argon2id = Argon2id( + parallelism: kdf.p, + memory: kdf.m, + iterations: kdf.t, + hashLength: 32, + ); + return argon2id.deriveKeyFromPassword(password: secret, nonce: salt); + } + + static Future createSlot({ + required String type, + required String secret, + required SecretKey mlk, + KdfParams kdf = const KdfParams(), + Uint8List? saltForTest, + List? nonceForTest, + }) async { + final salt = saltForTest ?? _randomBytes(16); + final kek = await deriveKek(secret: secret, salt: salt, kdf: kdf); + final box = await _aesGcm.encrypt( + await mlk.extractBytes(), + secretKey: kek, + nonce: nonceForTest ?? _aesGcm.newNonce(), + ); + return Keyslot( + type: type, + salt: salt, + kdf: kdf, + nonce: Uint8List.fromList(box.nonce), + wrapped: Uint8List.fromList([...box.cipherText, ...box.mac.bytes]), + ); + } + + static Future tryUnwrap({ + required KeyslotFile file, + required String secret, + }) async { + for (final slot in file.slots) { + final candidate = slot.type == 'recovery' + ? _normalizeRecovery(secret) + : secret; + final kek = await deriveKek( + secret: candidate, + salt: slot.salt, + kdf: slot.kdf, + ); + final box = SecretBox( + Uint8List.sublistView(slot.wrapped, 0, slot.wrapped.length - 16), + nonce: slot.nonce, + mac: Mac( + Uint8List.sublistView(slot.wrapped, slot.wrapped.length - 16), + ), + ); + try { + final mlkBytes = await _aesGcm.decrypt(box, secretKey: kek); + return SecretKey(mlkBytes); + } on SecretBoxAuthenticationError { + continue; + } + } + return null; + } + + static Future deriveDataKey(SecretKey mlk) async { + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + return hkdf.deriveKey( + secretKey: mlk, + nonce: const [], + info: utf8.encode('sbe:v1:data'), + ); + } + + // Replaced by RecoveryCode.normalize in Task 3; identity until then. + static String Function(String) _normalizeRecovery = (s) => s; + + static set normalizeRecoveryForWiring(String Function(String) fn) => + _normalizeRecovery = fn; + + static Uint8List _randomBytes(int n) { + final r = SecureRandom.safe; // from package:cryptography + return Uint8List.fromList(List.generate(n, (_) => r.nextInt(256))); + } +} +``` + +Implementation notes: +- If `SecureRandom.safe` is not exposed by cryptography 2.9.0, use `dart:math` `Random.secure()` instead — that is the standard Dart CSPRNG and is what the spec means by `Random.secure()`. +- The `_normalizeRecovery` mutable hook is temporary scaffolding; Task 3 replaces it with a direct call to `RecoveryCode.normalize` and DELETES the setter (grep for `normalizeRecoveryForWiring` afterwards — it must not survive Task 3). +- HKDF salt: python used `salt=None` which HKDF treats as zero-length; `nonce: const []` matches. The KAT proves it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/core/services/sync/crypto/keyslots_test.dart` +Expected: all PASS. If the Argon2id KAT fails, verify `memory` unit (KiB blocks) and that `deriveKeyFromPassword(password:, nonce:)` receives the SALT as nonce. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/sync/crypto/keyslots.dart \ + test/core/services/sync/crypto/keyslots_test.dart +git commit -m "feat(sync): keyslot file with Argon2id passphrase/recovery wrapping (#520)" +``` + +--- + +### Task 3: Recovery code — EFF wordlist + generate/normalize + +**Files:** +- Create: `scripts/generate_eff_wordlist.py` +- Create: `lib/core/services/sync/crypto/eff_short_wordlist.dart` (generated, committed) +- Create: `lib/core/services/sync/crypto/recovery_code.dart` +- Modify: `lib/core/services/sync/crypto/keyslots.dart` (replace the `_normalizeRecovery` scaffolding) +- Test: `test/core/services/sync/crypto/recovery_code_test.dart` + +**Interfaces:** +- Produces (used by Tasks 7, 9, 12, 13): + - `const List effShortWordlist` (exactly 1296 lowercase words) + - `abstract final class RecoveryCode { static String generate(); static String normalize(String input); }` — `generate()` returns 8 words joined by `-`; `normalize` lowercases, trims, and collapses any run of spaces/hyphens/newlines to a single `-`. + +- [ ] **Step 1: Write the wordlist generator** + +Create `scripts/generate_eff_wordlist.py`: + +```python +#!/usr/bin/env python3 +"""Fetches the EFF short wordlist (1296 words) and emits a Dart constant. + +Output: lib/core/services/sync/crypto/eff_short_wordlist.dart +""" +import urllib.request + +URL = "https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt" + +raw = urllib.request.urlopen(URL).read().decode() +words = [] +for line in raw.strip().splitlines(): + # lines look like: "1111\tacid" + parts = line.split() + words.append(parts[-1]) +assert len(words) == 1296, f"expected 1296 words, got {len(words)}" +assert len(set(words)) == 1296, "duplicate words" + +with open("lib/core/services/sync/crypto/eff_short_wordlist.dart", "w") as f: + f.write("/// EFF short wordlist #1 (1296 words), for recovery codes.\n") + f.write("/// Generated by scripts/generate_eff_wordlist.py. Do not edit.\n") + f.write("/// Source: https://www.eff.org/dice\n") + f.write("const List effShortWordlist = [\n") + for w in words: + f.write(f" '{w}',\n") + f.write("];\n") +print("wrote eff_short_wordlist.dart with", len(words), "words") +``` + +- [ ] **Step 2: Generate and eyeball the wordlist** + +Run: `python3 scripts/generate_eff_wordlist.py` +Expected: `wrote eff_short_wordlist.dart with 1296 words`. If the EFF URL is unreachable, STOP and ask the user rather than substituting a wordlist from memory. + +- [ ] **Step 3: Write the failing tests** + +Create `test/core/services/sync/crypto/recovery_code_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/eff_short_wordlist.dart'; +import 'package:submersion/core/services/sync/crypto/recovery_code.dart'; + +void main() { + group('RecoveryCode', () { + test('wordlist integrity: 1296 unique lowercase words', () { + expect(effShortWordlist.length, 1296); + expect(effShortWordlist.toSet().length, 1296); + for (final w in effShortWordlist) { + expect(w, matches(RegExp(r'^[a-z]+$'))); + } + }); + + test('generate returns 8 wordlist words joined by hyphens', () { + final code = RecoveryCode.generate(); + final words = code.split('-'); + expect(words.length, 8); + for (final w in words) { + expect(effShortWordlist.contains(w), isTrue, reason: w); + } + }); + + test('two generated codes differ', () { + expect(RecoveryCode.generate(), isNot(RecoveryCode.generate())); + }); + + test('normalize is tolerant of case, spaces, and separators', () { + expect( + RecoveryCode.normalize(' Acid ACORN\tacre - act\nadd age aid aim '), + 'acid-acorn-acre-act-add-age-aid-aim', + ); + expect( + RecoveryCode.normalize('acid-acorn-acre-act-add-age-aid-aim'), + 'acid-acorn-acre-act-add-age-aid-aim', + ); + }); + }); +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `flutter test test/core/services/sync/crypto/recovery_code_test.dart` +Expected: FAIL — `recovery_code.dart` missing. + +- [ ] **Step 5: Implement RecoveryCode and wire normalization into Keyslots** + +Create `lib/core/services/sync/crypto/recovery_code.dart`: + +```dart +import 'dart:math'; + +import 'package:submersion/core/services/sync/crypto/eff_short_wordlist.dart'; + +/// 8 words from the EFF short wordlist: ~10.34 bits/word, ~82.7 bits total. +abstract final class RecoveryCode { + static const int wordCount = 8; + + static String generate() { + final random = Random.secure(); + return List.generate( + wordCount, + (_) => effShortWordlist[random.nextInt(effShortWordlist.length)], + ).join('-'); + } + + /// Lowercase; any run of whitespace/hyphens becomes a single hyphen. + static String normalize(String input) => input + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[\s\-]+'), '-'); +} +``` + +In `lib/core/services/sync/crypto/keyslots.dart`, DELETE the `_normalizeRecovery` field and the `normalizeRecoveryForWiring` setter, add `import 'package:submersion/core/services/sync/crypto/recovery_code.dart';`, and change the `tryUnwrap` line to: + +```dart + final candidate = slot.type == 'recovery' + ? RecoveryCode.normalize(secret) + : secret; +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `flutter test test/core/services/sync/crypto/recovery_code_test.dart test/core/services/sync/crypto/keyslots_test.dart` +Expected: all PASS. Also run `grep -rn "normalizeRecoveryForWiring" lib/ test/` — expected: no matches. + +- [ ] **Step 7: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add scripts/generate_eff_wordlist.py lib/core/services/sync/crypto/ \ + test/core/services/sync/crypto/recovery_code_test.dart +git commit -m "feat(sync): recovery code generation on the EFF short wordlist (#520)" +``` + +--- + +### Task 4: EncryptionKeyStore — local custody + keyslot mirror + enabled flag + +**Files:** +- Create: `lib/core/services/sync/crypto/encryption_key_store.dart` +- Modify: `lib/core/services/sync/sync_preferences.dart` +- Test: `test/core/services/sync/crypto/encryption_key_store_test.dart` + +**Interfaces:** +- Consumes: `FallbackSecureStorage` (`lib/core/services/secure_storage/fallback_secure_storage.dart`; construct exactly like `S3CredentialsStore` does: `FallbackSecureStorage(storage ?? const FlutterSecureStorage())` — read `lib/core/services/cloud_storage/s3/s3_credentials_store.dart:20` first and mirror its read/write/delete API usage). +- Produces (used by Tasks 5, 7, 8, 9, 10): + - `class UnlockedKey { final String libraryKeyId; final SecretKey mlk; const UnlockedKey({required this.libraryKeyId, required this.mlk}); }` + - `class EncryptionKeyStore` with: + - `EncryptionKeyStore({FlutterSecureStorage? storage})` + - `Future saveKey({required String libraryKeyId, required List mlkBytes})` + - `Future loadKey()` + - `Future clearKey()` + - `Future saveKeyslotMirror(Uint8List bytes)` / `Future loadKeyslotMirror()` / `Future clearKeyslotMirror()` + - `SyncPreferences` gains: `bool get syncEncryptionEnabled` (default false) and `Future setSyncEncryptionEnabled(bool value)` under key `'sync_encryption_enabled'`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/sync/crypto/encryption_key_store_test.dart`. Look at an existing FallbackSecureStorage consumer test first (`grep -rln "FallbackSecureStorage" test/`) and reuse its in-memory `FlutterSecureStorage` faking approach; if none exists, fake at the `FlutterSecureStorage` level: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; + +class _MemorySecureStorage implements FlutterSecureStorage { + final Map values = {}; + @override + Future write({ + required String key, + required String? value, + dynamic aOptions, + dynamic iOptions, + dynamic lOptions, + dynamic mOptions, + dynamic wOptions, + dynamic webOptions, + }) async { + if (value == null) { + values.remove(key); + } else { + values[key] = value; + } + } + + @override + Future read({required String key, dynamic aOptions, + dynamic iOptions, dynamic lOptions, dynamic mOptions, + dynamic wOptions, dynamic webOptions}) async => values[key]; + + @override + Future delete({required String key, dynamic aOptions, + dynamic iOptions, dynamic lOptions, dynamic mOptions, + dynamic wOptions, dynamic webOptions}) async { + values.remove(key); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('saveKey/loadKey/clearKey round-trip', () async { + final store = EncryptionKeyStore(storage: _MemorySecureStorage()); + expect(await store.loadKey(), isNull); + final mlk = List.generate(32, (i) => i); + await store.saveKey( + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + mlkBytes: mlk, + ); + final loaded = await store.loadKey(); + expect(loaded!.libraryKeyId, '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'); + expect(await loaded.mlk.extractBytes(), mlk); + await store.clearKey(); + expect(await store.loadKey(), isNull); + }); + + test('keyslot mirror round-trip', () async { + final store = EncryptionKeyStore(storage: _MemorySecureStorage()); + expect(await store.loadKeyslotMirror(), isNull); + final bytes = Uint8List.fromList([1, 2, 3, 250]); + await store.saveKeyslotMirror(bytes); + expect(await store.loadKeyslotMirror(), bytes); + await store.clearKeyslotMirror(); + expect(await store.loadKeyslotMirror(), isNull); + }); + + test('SyncPreferences encryption flag defaults false and persists', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = SyncPreferences(await SharedPreferences.getInstance()); + expect(prefs.syncEncryptionEnabled, isFalse); + await prefs.setSyncEncryptionEnabled(true); + expect(prefs.syncEncryptionEnabled, isTrue); + }); +} +``` + +NOTE: if `FlutterSecureStorage`'s method signatures make the hand-fake awkward (named option types), check how existing tests fake it (`grep -rn "FlutterSecureStorage" test/ | head`) and copy that pattern instead — the store's API contract in the assertions is what matters, not the fake's shape. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/core/services/sync/crypto/encryption_key_store_test.dart` +Expected: FAIL — `encryption_key_store.dart` missing / SyncPreferences lacks the flag. + +- [ ] **Step 3: Implement store + preference** + +Create `lib/core/services/sync/crypto/encryption_key_store.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:submersion/core/services/secure_storage/fallback_secure_storage.dart'; + +/// The unlocked master key for the current encrypted library. +class UnlockedKey { + final String libraryKeyId; + final SecretKey mlk; + + const UnlockedKey({required this.libraryKeyId, required this.mlk}); +} + +/// Device-local custody of the master library key and a mirror of the last +/// keyslot file written to the cloud (needed for self-heal: the passphrase +/// is not retained, so the wrapped file cannot be regenerated locally). +class EncryptionKeyStore { + static const _keyIdKey = 'sync_encryption_library_key_id'; + static const _mlkKey = 'sync_encryption_mlk'; + static const _mirrorKey = 'sync_encryption_keyslot_mirror'; + + final FallbackSecureStorage _storage; + + EncryptionKeyStore({FlutterSecureStorage? storage}) + : _storage = FallbackSecureStorage(storage ?? const FlutterSecureStorage()); + + Future saveKey({ + required String libraryKeyId, + required List mlkBytes, + }) async { + await _storage.write(key: _keyIdKey, value: libraryKeyId); + await _storage.write(key: _mlkKey, value: base64Encode(mlkBytes)); + } + + Future loadKey() async { + final keyId = await _storage.read(key: _keyIdKey); + final mlk = await _storage.read(key: _mlkKey); + if (keyId == null || mlk == null) return null; + return UnlockedKey( + libraryKeyId: keyId, + mlk: SecretKey(base64Decode(mlk)), + ); + } + + Future clearKey() async { + await _storage.delete(key: _keyIdKey); + await _storage.delete(key: _mlkKey); + } + + Future saveKeyslotMirror(Uint8List bytes) => + _storage.write(key: _mirrorKey, value: base64Encode(bytes)); + + Future loadKeyslotMirror() async { + final v = await _storage.read(key: _mirrorKey); + return v == null ? null : base64Decode(v); + } + + Future clearKeyslotMirror() => _storage.delete(key: _mirrorKey); +} +``` + +BEFORE writing this, read `lib/core/services/secure_storage/fallback_secure_storage.dart` and match its actual method names/signatures (`write`/`read`/`delete` shown here are the expected shape; if it exposes different names, follow the real ones and adjust the test fake accordingly). + +Add to `lib/core/services/sync/sync_preferences.dart` (following the existing field pattern exactly): + +```dart + static const String _encryptionEnabledKey = 'sync_encryption_enabled'; +``` + +```dart + bool get syncEncryptionEnabled => + _prefs.getBool(_encryptionEnabledKey) ?? false; + + Future setSyncEncryptionEnabled(bool value) async { + await _prefs.setBool(_encryptionEnabledKey, value); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/core/services/sync/crypto/encryption_key_store_test.dart` +Expected: all PASS. +Memory note: there is a standing project memory that 4 settings-notifier mocks need updating whenever `SyncPreferences` changes shape — run `flutter test test/features/settings` and fix any mock fallout now, not later. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/sync/ test/core/services/sync/crypto/ \ + lib/core/services/sync/sync_preferences.dart +git commit -m "feat(sync): local key custody, keyslot mirror, encryption preference (#520)" +``` + +--- + +### Task 5: EncryptingCloudStorageProvider decorator + +**Files:** +- Create: `lib/core/services/cloud_storage/encrypting_cloud_storage_provider.dart` +- Test: `test/core/services/cloud_storage/encrypting_cloud_storage_provider_test.dart` + +**Interfaces:** +- Consumes: `CloudStorageProvider` (all members, `lib/core/services/cloud_storage/cloud_storage_provider.dart`), `SyncEnvelope`, `KeyslotFile.cloudFileName`, `FakeCloudStorageProvider` (`test/support/fake_cloud_storage_provider.dart`) in tests. +- Produces (used by Tasks 8, 11): + - `class EncryptingCloudStorageProvider implements CloudStorageProvider { EncryptingCloudStorageProvider(this.inner, {required SecretKey dataKey, required String libraryKeyId}); final CloudStorageProvider inner; static bool isExempt(String filename); }` + - Behavior contract: uploads seal non-exempt files; downloads open SBE1 bytes and pass through non-SBE1 bytes; id-to-name map populated from `listFiles`, `getFileInfo`, and `uploadFile` results; cache miss falls back to `getFileInfo`; all other members delegate. + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/cloud_storage/encrypting_cloud_storage_provider_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/cloud_storage/encrypting_cloud_storage_provider.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + +import '../../../support/fake_cloud_storage_provider.dart'; + +const _keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; + +void main() { + late FakeCloudStorageProvider inner; + late EncryptingCloudStorageProvider provider; + final dataKey = SecretKey(List.generate(32, (i) => i + 1)); + + setUp(() { + inner = FakeCloudStorageProvider(); + provider = EncryptingCloudStorageProvider( + inner, + dataKey: dataKey, + libraryKeyId: _keyId, + ); + }); + + Uint8List bytesOf(String s) => Uint8List.fromList(utf8.encode(s)); + + test('uploads are SBE1 at rest, downloads round-trip plaintext', () async { + final up = await provider.uploadFile( + bytesOf('{"cs":1}'), + 'ssv1.devA.cs.00001.json', + ); + // At rest (inner fake) the bytes must be an envelope: + final atRest = await inner.downloadFile(up.fileId); + expect(SyncEnvelope.hasMagic(atRest), isTrue); + // Through the decorator they come back as plaintext: + final roundTrip = await provider.downloadFile(up.fileId); + expect(utf8.decode(roundTrip), '{"cs":1}'); + }); + + test('keyslot file and backup artifacts are exempt', () async { + expect(EncryptingCloudStorageProvider.isExempt(KeyslotFile.cloudFileName), + isTrue); + expect( + EncryptingCloudStorageProvider.isExempt( + 'submersion_backup_2026-07-10.sbe'), + isTrue); + expect( + EncryptingCloudStorageProvider.isExempt('ssv1.devA.manifest.json'), + isFalse); + + final up = await provider.uploadFile( + bytesOf('{"slots":[]}'), + KeyslotFile.cloudFileName, + ); + final atRest = await inner.downloadFile(up.fileId); + expect(SyncEnvelope.hasMagic(atRest), isFalse); + expect(utf8.decode(atRest), '{"slots":[]}'); + }); + + test('plaintext files pass through downloads unchanged', () async { + final up = await inner.uploadFile( + bytesOf('{"legacy":true}'), + 'submersion_library_epoch.json', + ); + final viaDecorator = await provider.downloadFile(up.fileId); + expect(utf8.decode(viaDecorator), '{"legacy":true}'); + }); + + test('download resolves filename via listFiles for AAD', () async { + await provider.uploadFile(bytesOf('{"m":1}'), 'ssv1.devB.manifest.json'); + // fresh decorator instance = empty name cache; it must list or getFileInfo + final fresh = EncryptingCloudStorageProvider( + inner, + dataKey: dataKey, + libraryKeyId: _keyId, + ); + final files = await fresh.listFiles(namePattern: 'ssv1.'); + final m = files.singleWhere((f) => f.name == 'ssv1.devB.manifest.json'); + final bytes = await fresh.downloadFile(m.id); + expect(utf8.decode(bytes), '{"m":1}'); + }); + + test('delegates providerName/providerId and deleteFile', () async { + expect(provider.providerId, inner.providerId); + expect(provider.providerName, inner.providerName); + final up = await provider.uploadFile(bytesOf('x'), 'ssv1.devA.cs.1.json'); + await provider.deleteFile(up.fileId); + expect(await inner.fileExists(up.fileId), isFalse); + }); +} +``` + +BEFORE writing: read `test/support/fake_cloud_storage_provider.dart` to confirm constructor and whether `uploadFile` requires `folderId`; adapt the calls (not the assertions) to its real API. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/core/services/cloud_storage/encrypting_cloud_storage_provider_test.dart` +Expected: FAIL — decorator missing. + +- [ ] **Step 3: Implement the decorator** + +Create `lib/core/services/cloud_storage/encrypting_cloud_storage_provider.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + +/// Wraps the configured provider so every non-exempt byte stored in the +/// cloud is an SBE1 envelope. Filenames, listing, folders, and auth pass +/// through untouched; encryption is invisible to readers and writers above +/// this seam (spec section 4.1). +class EncryptingCloudStorageProvider implements CloudStorageProvider { + final CloudStorageProvider inner; + final SecretKey _dataKey; + final String _libraryKeyId; + + /// fileId -> filename, populated from list/info/upload results so + /// downloads know the AAD. Misses fall back to [getFileInfo]. + final Map _names = {}; + + EncryptingCloudStorageProvider( + this.inner, { + required SecretKey dataKey, + required String libraryKeyId, + }) : _dataKey = dataKey, + _libraryKeyId = libraryKeyId; + + /// Exactly two exemptions (spec 4.1): the keyslot bootstrap file and + /// backup artifacts, which carry their own framed encryption. + static bool isExempt(String filename) => + filename == KeyslotFile.cloudFileName || + filename.startsWith('submersion_backup_'); + + @override + Future uploadFile( + Uint8List data, + String filename, { + String? folderId, + }) async { + final bytes = isExempt(filename) + ? data + : await SyncEnvelope.seal( + plaintext: data, + dataKey: _dataKey, + libraryKeyId: _libraryKeyId, + filename: filename, + ); + final result = await inner.uploadFile(bytes, filename, folderId: folderId); + _names[result.fileId] = filename; + return result; + } + + @override + Future downloadFile(String fileId) async { + final bytes = await inner.downloadFile(fileId); + if (!SyncEnvelope.hasMagic(bytes)) return bytes; + final name = _names[fileId] ?? (await inner.getFileInfo(fileId))?.name; + if (name == null) { + throw const EnvelopeCorruptException( + 'Encrypted file has no resolvable name for authentication', + ); + } + _names[fileId] = name; + return SyncEnvelope.open( + envelope: bytes, + dataKey: _dataKey, + expectedLibraryKeyId: _libraryKeyId, + filename: name, + ); + } + + @override + Future> listFiles({ + String? folderId, + String? namePattern, + }) async { + final files = + await inner.listFiles(folderId: folderId, namePattern: namePattern); + for (final f in files) { + _names[f.id] = f.name; + } + return files; + } + + @override + Future getFileInfo(String fileId) async { + final info = await inner.getFileInfo(fileId); + if (info != null) _names[info.id] = info.name; + return info; + } + + @override + String get providerName => inner.providerName; + @override + String get providerId => inner.providerId; + @override + Future isAvailable() => inner.isAvailable(); + @override + Future isAuthenticated() => inner.isAuthenticated(); + @override + Future authenticate() => inner.authenticate(); + @override + Future signOut() => inner.signOut(); + @override + Future getUserEmail() => inner.getUserEmail(); + @override + Future deleteFile(String fileId) => inner.deleteFile(fileId); + @override + Future fileExists(String fileId) => inner.fileExists(fileId); + @override + Future createFolder(String folderName, {String? parentFolderId}) => + inner.createFolder(folderName, parentFolderId: parentFolderId); + @override + Future getOrCreateSyncFolder() => inner.getOrCreateSyncFolder(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/core/services/cloud_storage/encrypting_cloud_storage_provider_test.dart` +Expected: all PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/cloud_storage/encrypting_cloud_storage_provider.dart \ + test/core/services/cloud_storage/ +git commit -m "feat(sync): encrypting cloud storage decorator (#520)" +``` + +--- + +### Task 6: Locked-library detection + awaitingPassphrase status + +**Files:** +- Modify: `lib/core/services/sync/sync_service.dart` (enum + `readLibraryEpochMarker` + `performSync` catch + `_runEpochGate` catch ordering) +- Modify: `lib/core/services/sync/changeset_log/sync_manifest.dart` (magic guard in `fromBytes`) +- Test: `test/core/services/sync/sync_encryption_gate_test.dart` + +**Interfaces:** +- Consumes: `SyncEnvelope.hasMagic` / `libraryKeyIdOf`, `SyncEncryptionRequired` (Task 1). +- Produces (used by Tasks 8, 11, 13): + - `SyncResultStatus.awaitingPassphrase` (new enum value, after `awaitingAdoption`). + - `performSync` returns `SyncResult(status: SyncResultStatus.awaitingPassphrase, message: ...)` whenever a `SyncEncryptionRequired` escapes any step. + - `readLibraryEpochMarker` throws `SyncEncryptionRequired` (not `FormatException`) when the marker bytes carry the SBE1 magic. + - `SyncManifest.fromBytes` throws `SyncEncryptionRequired` when given SBE1 bytes (defense in depth). + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/sync/sync_encryption_gate_test.dart`. Study `test/core/services/sync/` for how existing suites construct `SyncService` with the fake provider (e.g. the epoch tests) and reuse that scaffolding verbatim. The three behaviors to pin: + +```dart +// Test 1 — legacy fail-closed pin (THE safety property, spec section 6): +// an SBE1 envelope is never parseable as a JSON epoch marker. +test('SBE1 bytes can never satisfy the legacy epoch-marker parse', () async { + final envelope = await SyncEnvelope.seal( + plaintext: Uint8List.fromList(utf8.encode('{"epochId":"e1"}')), + dataKey: SecretKey(List.generate(32, (i) => i)), + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + filename: libraryEpochFileName, + ); + // This IS the legacy client's exact parse sequence (fail-closed path): + expect( + () => LibraryEpochMarker.fromJson( + jsonDecode(utf8.decode(envelope)) as Map, + ), + throwsA(anything), // utf8/json/FormatException — any throw = fail closed + ); +}); + +// Test 2 — current client: encrypted marker + no key -> awaitingPassphrase. +test('performSync halts awaitingPassphrase on an encrypted marker', () async { + // Arrange: fake cloud containing an SBE1-envelope epoch marker (upload the + // sealed bytes via the RAW fake provider), SyncService built WITHOUT any + // encryption key (raw provider, as a locked device would be). + // Act: final result = await syncService.performSync(); + // Assert: + // expect(result.status, SyncResultStatus.awaitingPassphrase); +}); + +// Test 3 — SyncManifest.fromBytes rejects envelopes explicitly. +test('SyncManifest.fromBytes throws SyncEncryptionRequired on SBE1', () async { + final envelope = await SyncEnvelope.seal( + plaintext: Uint8List.fromList(utf8.encode('{}')), + dataKey: SecretKey(List.generate(32, (i) => i)), + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + filename: 'ssv1.devA.manifest.json', + ); + expect( + () => SyncManifest.fromBytes(envelope), + throwsA(isA()), + ); +}); +``` + +Flesh out Test 2's arrange block from the existing epoch test scaffolding (`test/core/services/sync/sync_service_epoch_test.dart` or nearest equivalent — find with `grep -rln "_runEpochGate\|awaitingAdoption" test/core/services/sync/ | head -3`). Write real code, not comments, once the scaffolding pattern is in front of you. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/core/services/sync/sync_encryption_gate_test.dart` +Expected: Test 1 may already pass (it pins existing legacy behavior — good, keep it); Tests 2 and 3 FAIL (no enum value, no magic guards). + +- [ ] **Step 3: Implement the guards and status** + +In `lib/core/services/sync/sync_service.dart`: + +1. Add to the enum (after `awaitingAdoption,`): + +```dart + awaitingPassphrase, +``` + +2. In `readLibraryEpochMarker` (currently ~line 2694), after the download and BEFORE `jsonDecode(utf8.decode(bytes))`: + +```dart + if (SyncEnvelope.hasMagic(bytes)) { + throw SyncEncryptionRequired( + libraryKeyId: SyncEnvelope.libraryKeyIdOf(bytes), + message: 'The library epoch marker is encrypted', + ); + } +``` + +3. In `_runEpochGate`'s try/catch around `readLibraryEpochMarker` (~line 560): rethrow `SyncEncryptionRequired` so it reaches performSync's handler instead of the generic fail-closed halt: + +```dart + } on SyncEncryptionRequired { + rethrow; + } catch (e) { +``` + +4. In `performSync`'s outermost error handling (find the existing `on CloudStorageException catch` at ~line 531 and add a sibling BEFORE the generic catch): + +```dart + } on SyncEncryptionRequired catch (e) { + _log.info('Sync halted: encrypted library requires a passphrase'); + return SyncResult( + status: SyncResultStatus.awaitingPassphrase, + message: e.message, + ); + } +``` + +Add the needed imports (`crypto_errors.dart`, `sync_envelope.dart`). + +In `lib/core/services/sync/changeset_log/sync_manifest.dart`, at the top of `fromBytes` (read the file first; add before its jsonDecode): + +```dart + if (SyncEnvelope.hasMagic(bytes)) { + throw SyncEncryptionRequired( + libraryKeyId: SyncEnvelope.libraryKeyIdOf( + Uint8List.fromList(bytes), + ), + message: 'Sync manifest is encrypted', + ); + } +``` + +(Match `fromBytes`'s actual parameter type — if it takes `Uint8List` drop the wrapping.) + +- [ ] **Step 4: Run tests + neighboring sync suites** + +Run: `flutter test test/core/services/sync/sync_encryption_gate_test.dart test/core/services/sync/sync_service_epoch_test.dart test/core/services/sync/changeset_sync_convergence_test.dart` +Expected: all PASS (plaintext flows unaffected: the guards only fire on SBE1 magic). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/sync/ test/core/services/sync/ +git commit -m "feat(sync): awaitingPassphrase halt for encrypted libraries (#520)" +``` + +--- + +### Task 7: SyncEncryptionService — enable/unlock/disable/change/regenerate + self-heal + +**Files:** +- Create: `lib/core/services/sync/crypto/sync_encryption_service.dart` +- Modify: `lib/core/services/sync/sync_service.dart` (keyslot self-heal after a successful publish) +- Test: `test/core/services/sync/crypto/sync_encryption_service_test.dart` + +**Interfaces:** +- Consumes: `Keyslots`, `KeyslotFile`, `RecoveryCode`, `EncryptionKeyStore`, `UnlockedKey`, `CloudStorageProvider` (RAW, undecorated), `LibraryEpochStore.setPendingReplace(LibraryEpochMarker)` (read `lib/core/services/sync/library_epoch_store.dart:39-47` first), `LibraryEpochMarker`, `SyncPreferences`. +- Produces (used by Tasks 8, 10, 13): + - `class EnableEncryptionResult { final String recoveryCode; final String libraryKeyId; }` + - `class SyncEncryptionService` with: + - `SyncEncryptionService({required EncryptionKeyStore keyStore, required SyncPreferences preferences})` + - `Future enable({required CloudStorageProvider rawProvider, required String passphrase, required LibraryEpochStore epochStore, required String deviceId, String? deviceName, String? appVersion})` — generates MLK + recovery code, builds + uploads the keyslot file, saves key + mirror, sets the preference flag, mints a `LibraryEpochMarker` and calls `epochStore.setPendingReplace(marker)`. It does NOT run the sync; the caller triggers `performSync()` afterwards, which consumes the pending replace through the (now-encrypting) provider — the existing resumable path. + - `Future unlock({required CloudStorageProvider rawProvider, required String secret})` — downloads `KeyslotFile.cloudFileName` (list by name, download), `Keyslots.tryUnwrap`; on success saves key + mirror and returns; throws `WrongPassphraseException` (define beside the service) when no slot opens; throws `SyncEncryptionRequired` when the keyslot file is absent. + - `Future disable({required LibraryEpochStore epochStore, required String deviceId, String? deviceName, String? appVersion})` — clears the preference flag FIRST (so the next provider resolution is raw), mints + sets a pending replace marker; caller then syncs (plaintext republish) and finally calls `deleteCloudKeyslots(rawProvider)`. + - `Future deleteCloudKeyslots(CloudStorageProvider rawProvider)` + - `Future changePassphrase({required CloudStorageProvider rawProvider, required String currentSecret, required String newPassphrase})` — unlock with current, `withReplacedSlot(newPassphraseSlot)`, upload + mirror. + - `Future regenerateRecoveryCode({required CloudStorageProvider rawProvider, required String passphrase})` — same shape, replaces the `'recovery'` slot, returns the new code. + - `Future selfHealKeyslots(CloudStorageProvider rawProvider)` — when the flag is on and a mirror exists but the cloud file is missing, re-upload the mirror. +- Produces in `SyncService`: after a successful publish inside `performSync`, call `selfHealKeyslots` when an injected `SyncEncryptionService?` is present (new optional constructor parameter `encryptionService`, default null so every existing construction/test compiles unchanged). + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/sync/crypto/sync_encryption_service_test.dart` using `FakeCloudStorageProvider` + `SharedPreferences.setMockInitialValues({})` + the `_MemorySecureStorage` fake from Task 4 (extract that fake into `test/support/memory_secure_storage.dart` now and update Task 4's test import — one shared fake, no copies). Cover, with real assertions: + +```dart +// 1. enable(): cloud now holds a plaintext keyslot file that tryUnwrap opens +// with the passphrase AND with the returned recovery code; local key + +// mirror saved; prefs flag true; epochStore.pendingReplace != null. +// 2. unlock() with the passphrase on a SECOND (empty) key store: loadKey() +// returns the same MLK bytes as device A's. +// 3. unlock() with a wrong secret throws WrongPassphraseException; with no +// keyslot file in the cloud throws SyncEncryptionRequired. +// 4. changePassphrase(): old passphrase no longer unlocks, new one does, +// recovery code STILL does, libraryKeyId unchanged. +// 5. regenerateRecoveryCode(): old code dead, new code unlocks. +// 6. disable(): prefs flag false, pendingReplace set; +// deleteCloudKeyslots removes the file; loadKey() STILL returns the key +// (kept for old encrypted backups). +// 7. selfHealKeyslots(): delete the cloud file, call selfHeal, file is back +// byte-identical to the mirror. +``` + +Write each as a real `test(...)` with concrete arrange/act/assert — the comment list above is the coverage contract, not the test body. Use fast KDF params by passing a `KdfParams(m: 1024, t: 3, p: 1)` — give `enable`/`changePassphrase`/`regenerateRecoveryCode` an optional `KdfParams kdf = const KdfParams()` parameter for this. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/core/services/sync/crypto/sync_encryption_service_test.dart` +Expected: FAIL — service missing. + +- [ ] **Step 3: Implement the service** + +Create `lib/core/services/sync/crypto/sync_encryption_service.dart` implementing exactly the interface block above. Key mechanics: + +```dart +// enable() core: +final mlkBytes = _randomBytes(32); // Random.secure() +final mlk = SecretKey(mlkBytes); +final libraryKeyId = const Uuid().v4(); +final recoveryCode = RecoveryCode.generate(); +final file = KeyslotFile( + version: 1, + libraryKeyId: libraryKeyId, + slots: [ + await Keyslots.createSlot( + type: 'passphrase', secret: passphrase, mlk: mlk, kdf: kdf), + await Keyslots.createSlot( + type: 'recovery', secret: recoveryCode, mlk: mlk, kdf: kdf), + ], +); +final bytes = file.toJsonBytes(); +final folderId = await rawProvider.getOrCreateSyncFolder(); +await rawProvider.uploadFile(bytes, KeyslotFile.cloudFileName, + folderId: folderId); +await _keyStore.saveKey(libraryKeyId: libraryKeyId, mlkBytes: mlkBytes); +await _keyStore.saveKeyslotMirror(bytes); +await _preferences.setSyncEncryptionEnabled(true); +final marker = LibraryEpochMarker( + epochId: const Uuid().v4(), + replacedAt: DateTime.now().millisecondsSinceEpoch, + deviceId: deviceId, + deviceName: deviceName, + appVersion: appVersion, +); +await epochStore.setPendingReplace(marker); +return EnableEncryptionResult( + recoveryCode: recoveryCode, libraryKeyId: libraryKeyId); +``` + +```dart +// unlock() cloud fetch: list by exact name, mirror readLibraryEpochMarker's +// conflict-copy filtering: +final files = await rawProvider.listFiles( + namePattern: KeyslotFile.cloudFileName); +final match = files.where((f) => f.name == KeyslotFile.cloudFileName); +if (match.isEmpty) { + throw const SyncEncryptionRequired( + message: 'No keyslot file found in the cloud'); +} +final fileBytes = await rawProvider.downloadFile(match.first.id); +final keyslotFile = KeyslotFile.fromJsonBytes(fileBytes); +final mlk = await Keyslots.tryUnwrap(file: keyslotFile, secret: secret); +if (mlk == null) throw const WrongPassphraseException(); +await _keyStore.saveKey( + libraryKeyId: keyslotFile.libraryKeyId, + mlkBytes: await mlk.extractBytes()); +await _keyStore.saveKeyslotMirror(fileBytes); +return UnlockedKey(libraryKeyId: keyslotFile.libraryKeyId, mlk: mlk); +``` + +`WrongPassphraseException` is a plain `class WrongPassphraseException implements Exception { const WrongPassphraseException(); }` in the same file. + +`selfHealKeyslots`: only act when `_preferences.syncEncryptionEnabled` and `loadKeyslotMirror() != null` and a `listFiles(namePattern: KeyslotFile.cloudFileName)` exact-name match is empty; then upload the mirror. + +In `lib/core/services/sync/sync_service.dart`: add `final SyncEncryptionService? _encryptionService;` + optional constructor param `SyncEncryptionService? encryptionService`; in `performSync`, immediately after the publish step succeeds (locate the writer/publish invocation), add: + +```dart + final enc = _encryptionService; + if (enc != null) { + try { + await enc.selfHealKeyslots(provider); + } catch (e) { + _log.warning('Keyslot self-heal failed (non-fatal): $e'); + } + } +``` + +NOTE: `provider` here is the DECORATED provider, but keyslot upload/list are exempt names, so raw-vs-decorated is equivalent for this call — no separate raw handle needed inside SyncService. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/core/services/sync/crypto/sync_encryption_service_test.dart test/core/services/sync/crypto/encryption_key_store_test.dart` +Expected: all PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/sync/ test/core/services/sync/ test/support/ +git commit -m "feat(sync): encryption lifecycle service - enable, unlock, rotate slots, self-heal (#520)" +``` + +--- + +### Task 8: Riverpod wiring — key state, provider wrap, notifier handling + +**Files:** +- Modify: `lib/features/settings/presentation/providers/sync_providers.dart` +- Test: `test/features/settings/presentation/providers/sync_encryption_providers_test.dart` + +**Interfaces:** +- Consumes: `EncryptionKeyStore`, `UnlockedKey`, `Keyslots.deriveDataKey`, `EncryptingCloudStorageProvider`, `SyncEncryptionService`, `SyncPreferences`, existing `cloudStorageProviderProvider` / `syncServiceProvider` / the sync notifier at `sync_providers.dart` (~line 445 and ~759). +- Produces (used by Tasks 10, 12, 13): + - `final encryptionKeyStoreProvider = Provider(...)` + - `final syncEncryptionServiceProvider = Provider(...)` + - `class EncryptionKeyNotifier extends StateNotifier` with `Future ensureLoaded()` (memoized load from the key store), `Future setUnlocked(UnlockedKey key)`, `Future clear()`; exposed as `final encryptionKeyNotifierProvider = StateNotifierProvider(...)`. + - `final encryptionDataKeyProvider = FutureProvider(...)` — HKDF of the current key, null when locked/disabled. + - `cloudStorageProviderProvider` wraps: when `syncEncryptionEnabled` is true AND an `UnlockedKey` is present in `encryptionKeyNotifierProvider` state AND a data key is derivable, return `EncryptingCloudStorageProvider(raw, dataKey: ..., libraryKeyId: ...)`; otherwise return the raw provider. Because `deriveDataKey` is async and the provider is sync, derive the data key inside `EncryptionKeyNotifier.setUnlocked`/`ensureLoaded` and cache it on a small immutable state object — change the notifier state type to `EncryptionSessionState { final UnlockedKey key; final SecretKey dataKey; }?` so the provider wrap is synchronous. (Drop `encryptionDataKeyProvider` if this state shape makes it redundant — prefer the single state object.) + - Sync notifier: before any `performSync` call it makes, `await ref.read(encryptionKeyNotifierProvider.notifier).ensureLoaded();` (fixes the launch-sync race); where it maps `awaitingAdoption` (~line 759), add an `awaitingPassphrase` branch that surfaces a `needsPassphrase: true` flag on `SyncState` (add the field with default false, wired through its copyWith if present). + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/settings/presentation/providers/sync_encryption_providers_test.dart` with a `ProviderContainer` (mirror an existing container-based test in `test/features/settings/presentation/providers/` — find one with `grep -rln "ProviderContainer" test/features/settings | head -3`): + +```dart +// 1. With encryption disabled (default prefs), cloudStorageProviderProvider +// returns the raw provider type (not EncryptingCloudStorageProvider). +// 2. With prefs flag true + notifier seeded via setUnlocked(key), the +// resolved provider IS an EncryptingCloudStorageProvider. +// 3. ensureLoaded() populates state from a key store that has a saved key. +// 4. clear() drops the state and the resolved provider reverts to raw. +``` + +Write these as real tests. Provider overrides: override the prefs/key-store providers with fakes; override `selectedCloudProviderTypeProvider` (or whatever the existing tests override to make `cloudStorageProviderProvider` non-null — copy their approach). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/settings/presentation/providers/sync_encryption_providers_test.dart` +Expected: FAIL — providers missing. + +- [ ] **Step 3: Implement the wiring** + +In `lib/features/settings/presentation/providers/sync_providers.dart`, add (near the other service providers): + +```dart +final encryptionKeyStoreProvider = Provider((ref) { + return EncryptionKeyStore(); +}); + +final syncEncryptionServiceProvider = Provider((ref) { + return SyncEncryptionService( + keyStore: ref.watch(encryptionKeyStoreProvider), + preferences: ref.watch(syncPreferencesProvider), + ); +}); + +/// Unlocked-session state: null = disabled or locked. +class EncryptionSessionState { + final UnlockedKey key; + final SecretKey dataKey; + const EncryptionSessionState({required this.key, required this.dataKey}); +} + +class EncryptionKeyNotifier extends StateNotifier { + EncryptionKeyNotifier(this._keyStore, this._preferences) : super(null); + + final EncryptionKeyStore _keyStore; + final SyncPreferences _preferences; + Future? _loading; + + Future ensureLoaded() { + return _loading ??= _load(); + } + + Future _load() async { + if (state != null) return state; + // The stored key outlives a disable (old encrypted backups stay + // restorable via EncryptionKeyStore directly); a SESSION only exists + // while the feature flag is on, so the provider wrap follows the flag. + if (!_preferences.syncEncryptionEnabled) return null; + final key = await _keyStore.loadKey(); + if (key == null) return null; + final dataKey = await Keyslots.deriveDataKey(key.mlk); + if (!mounted) return null; + state = EncryptionSessionState(key: key, dataKey: dataKey); + return state; + } + + Future setUnlocked(UnlockedKey key) async { + final dataKey = await Keyslots.deriveDataKey(key.mlk); + if (!mounted) return; + state = EncryptionSessionState(key: key, dataKey: dataKey); + _loading = Future.value(state); + } + + Future clear() async { + state = null; + _loading = Future.value(null); + } +} + +final encryptionKeyNotifierProvider = + StateNotifierProvider( + (ref) => EncryptionKeyNotifier( + ref.watch(encryptionKeyStoreProvider), + ref.watch(syncPreferencesProvider), + ), +); +``` + +Session-vs-key rule (referenced by Task 12): `EncryptionKeyStore` retains the +key across a disable so old encrypted backups restore silently; the notifier +SESSION exists only while `syncEncryptionEnabled` is true, and the disable +flow calls `clear()` so `cloudStorageProviderProvider` (which watches only +the session) rebuilds to the raw provider immediately. + +NOTE: check how `syncPreferencesProvider` is actually named/exposed in this file (`grep -n "SyncPreferences" lib/features/settings/presentation/providers/sync_providers.dart`) and use the real symbol; if preferences are read synchronously elsewhere via a different provider, follow that pattern. + +Change `cloudStorageProviderProvider` (line ~182) to wrap: + +```dart +final cloudStorageProviderProvider = Provider((ref) { + final storageConfigState = ref.watch(storageConfigNotifierProvider); + if (storageConfigState.config.mode == StorageLocationMode.customFolder) { + return null; + } + + final providerType = ref.watch(selectedCloudProviderTypeProvider); + if (providerType == null) return null; + + final raw = cloudProviderInstanceFor(providerType); + final session = ref.watch(encryptionKeyNotifierProvider); + if (session == null) return raw; + return EncryptingCloudStorageProvider( + raw, + dataKey: session.dataKey, + libraryKeyId: session.key.libraryKeyId, + ); +}); +``` + +Wire `encryptionService: ref.watch(syncEncryptionServiceProvider)` into the `SyncService(...)` construction in `syncServiceProvider`. + +In the sync notifier: add `await _ref.read(encryptionKeyNotifierProvider.notifier).ensureLoaded();` at the top of its sync-triggering method (the one that calls `performSync` — find it near line 445), and extend the result handling near line 759: + +```dart + if (result.status == SyncResultStatus.awaitingPassphrase) { + // surface the locked state; the Cloud Sync page shows the + // unlock banner from this flag. + state = state.copyWith( + status: SyncStatus.error, + message: result.message, + needsPassphrase: true, + ); + return; + } +``` + +Add `final bool needsPassphrase;` (default false) to `SyncState` + its `copyWith`, mirroring how existing fields are declared in that class (~line 205 in the same file). + +- [ ] **Step 4: Run tests + settings suite** + +Run: `flutter test test/features/settings/presentation/providers/sync_encryption_providers_test.dart test/features/settings` +Expected: all PASS (fix any settings-notifier mock fallout — see the standing 4-mock memory). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/settings/presentation/providers/sync_providers.dart \ + test/features/settings/ +git commit -m "feat(sync): riverpod wiring for encryption session and provider wrap (#520)" +``` + +--- + +### Task 9: BackupCrypto — framed .sbe with embedded keyslots + +**Files:** +- Create: `lib/features/backup/data/services/backup_crypto.dart` +- Test: `test/features/backup/data/services/backup_crypto_test.dart` + +**Interfaces:** +- Consumes: `Keyslots` (deriveDataKey, tryUnwrap), `KeyslotFile`, `SyncEnvelope.magic` constant, `crypto_errors.dart`. +- Produces (used by Task 10): + - `abstract final class BackupCrypto` with: + - `static const String fileExtension = '.sbe';` + - `static const int frameSize = 8 * 1024 * 1024;` + - `static Future encryptFile({required String inPath, required String outPath, required SecretKey mlk, required String libraryKeyId, required Uint8List keyslotBytes})` + - `static Future decryptFile({required String inPath, required String outPath, required String secret})` — reads embedded keyslots, `tryUnwrap`, throws `WrongPassphraseException` on failure (import from `sync_encryption_service.dart`). + - `static Future decryptFileWithKey({required String inPath, required String outPath, required SecretKey mlk, required String expectedLibraryKeyId})` — silent path when the key is already cached; throws `SyncEncryptionRequired` on keyId mismatch. + - `static Future isEncryptedBackup(String path)` — first 4 bytes == magic. + - `static Future libraryKeyIdOf(String path)` + - Wire format (spec 3.4, frozen): header = `SBE1`(4) | keyId(16) | flags byte `0x02` (framed) | uint32 BE keyslotBlockLen | keyslot JSON; then frames: uint32 BE frameLen | nonce(12) | ciphertext||tag, with AAD = keyId16 || uint64 BE frameIndex || finalFlag byte (1 for the last frame, else 0). Frame plaintext chunks are `frameSize` except the last. + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/backup/data/services/backup_crypto_test.dart`: + +```dart +// Build all fixtures with fast KdfParams(m: 1024, t: 3, p: 1). +// 1. Round-trip: write a 20 MiB pseudo-random temp file (deterministic +// generator, NOT Random() without seed), encryptFile, decryptFile with +// the passphrase, byte-identical output. (3 frames: 8+8+4 MiB.) +// 2. decryptFileWithKey round-trips silently with matching keyId; throws +// SyncEncryptionRequired with a mismatched expectedLibraryKeyId. +// 3. Wrong secret -> WrongPassphraseException. +// 4. Recovery code (normalized entry with spaces/case) decrypts. +// 5. Tamper: flip one byte inside frame 1 -> EnvelopeCorruptException. +// 6. Truncation: cut the file before the final frame -> +// EnvelopeCorruptException (missing final flag = truncated). +// 7. Frame reorder: swap frame 0 and frame 1 byte ranges -> +// EnvelopeCorruptException (frameIndex is in the AAD). +// 8. isEncryptedBackup: true for output, false for the plain input. +``` + +Write each as a real test with concrete bytes; use `Directory.systemTemp.createTemp('backup_crypto_')` for the temp files and delete in `tearDown`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/backup/data/services/backup_crypto_test.dart` +Expected: FAIL — file missing. + +- [ ] **Step 3: Implement BackupCrypto** + +Create `lib/features/backup/data/services/backup_crypto.dart`. Core encrypt loop (bounded memory — never read the whole file): + +```dart +static Future encryptFile({ + required String inPath, + required String outPath, + required SecretKey mlk, + required String libraryKeyId, + required Uint8List keyslotBytes, +}) async { + final dataKey = await Keyslots.deriveDataKey(mlk); + final keyIdBytes = UuidValue.withValidation(libraryKeyId).toBytes(); + final aes = AesGcm.with256bits(); + final input = File(inPath).openSync(); + final out = File(outPath).openSync(mode: FileMode.write); + try { + out.writeFromSync(SyncEnvelope.magic); + out.writeFromSync(keyIdBytes); + out.writeByteSync(0x02); // framed + out.writeFromSync(_uint32be(keyslotBytes.length)); + out.writeFromSync(keyslotBytes); + final total = input.lengthSync(); + var offset = 0; + var frameIndex = 0; + while (offset < total) { + final len = (total - offset).clamp(0, frameSize) as int; + final chunk = input.readSync(len); + offset += len; + final isFinal = offset >= total; + final box = await aes.encrypt( + chunk, + secretKey: dataKey, + aad: _frameAad(keyIdBytes, frameIndex, isFinal), + ); + final body = [...box.nonce, ...box.cipherText, ...box.mac.bytes]; + out.writeFromSync(_uint32be(body.length)); + out.writeFromSync(body); + frameIndex++; + } + } finally { + input.closeSync(); + out.closeSync(); + } +} + +static List _frameAad(List keyId16, int index, bool isFinal) { + final b = ByteData(8)..setUint64(0, index); + return [...keyId16, ...b.buffer.asUint8List(), isFinal ? 1 : 0]; +} + +static Uint8List _uint32be(int v) => + Uint8List(4)..buffer.asByteData().setUint32(0, v); +``` + +Decrypt mirrors it: parse header, `KeyslotFile.fromJsonBytes(slotBytes)`, unwrap (or accept an MLK), then loop frames verifying each; track `sawFinal` — if EOF arrives without a final-flagged frame authenticating, throw `EnvelopeCorruptException('Backup truncated')`. A frame that fails auth (tamper/reorder) throws `EnvelopeCorruptException`. Zero-length input file: write zero frames plus a single empty final frame so decrypt has a final marker (`if (total == 0)` special case; test 1 covers the normal path — add a small empty-file test if trivial). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/features/backup/data/services/backup_crypto_test.dart` +Expected: all PASS. The 20 MiB round trip should run in seconds; if it takes minutes, something is copying per-byte — fix before committing. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/backup/data/services/backup_crypto.dart \ + test/features/backup/data/services/backup_crypto_test.dart +git commit -m "feat(backup): framed self-decrypting .sbe backup encryption (#520)" +``` + +--- + +### Task 10: BackupService integration — encrypt uploads, decrypt restores, cleanup + +**Files:** +- Modify: `lib/features/backup/data/services/backup_service.dart` +- Test: `test/features/backup/data/services/backup_service_encryption_test.dart` + +**Interfaces:** +- Consumes: `BackupCrypto`, `EncryptionKeyStore`, `SyncPreferences` flag, existing `_uploadToCloud(String localPath, String filename)` (~line 843), `restoreFromFile(String filePath, {RestoreMode mode})` (~line 384), `restoreFromBackup(BackupRecord record, {RestoreMode mode})` (~line 317), `_downloadFromCloud` (~line 858), the `.db`/`.sqlite` extension validation (~line 263). +- Produces (used by Task 13): + - `BackupService` constructor gains optional `EncryptionKeyStore? encryptionKeyStore` and `SyncPreferences? syncPreferences` (both default null = plaintext behavior; every existing construction/test compiles unchanged). + - `_uploadToCloud` uploads `submersion_backup_.sbe` (encrypted temp file) when the flag is on and key + mirror are available; falls back to plaintext with a logged warning when the flag is on but key/mirror are missing (never silently — log at error level). + - `restoreFromFile(String filePath, {RestoreMode mode, String? encryptionSecret})`: when `BackupCrypto.isEncryptedBackup` — try cached key (`decryptFileWithKey`) first; else if `encryptionSecret != null` use `decryptFile`; else throw `BackupEncryptedException` (new, in `lib/features/backup/domain/exceptions/` beside existing backup exceptions — read that directory and match style). Decrypts to a temp `.db` then continues down the existing path. + - `restoreFromBackup(..., {String? encryptionSecret})` threads the parameter to the file path it resolves. + - Extension validation accepts `.sbe` alongside `.db`/`.sqlite`. + - `Future deletePlaintextCloudBackups()` — lists the cloud backup folder, deletes files matching `submersion_backup_*.db`, returns the count. + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/backup/data/services/backup_service_encryption_test.dart`. Reuse the existing `backup_service_test.dart` scaffolding (it already defines a local fake provider at its line ~104 — import or mirror how it builds a `BackupService` with fakes). Cover with real tests: + +```dart +// 1. With encryption on (flag + saved key + mirror), performBackup uploads +// a cloud file named submersion_backup_*.sbe whose first 4 bytes are +// SBE1; the LOCAL artifact stays a readable SQLite .db. +// 2. With encryption off, cloud upload keeps today's .db name (regression). +// 3. restoreFromFile on an encrypted artifact with no secret and no cached +// key throws BackupEncryptedException. +// 4. restoreFromFile succeeds with the passphrase (via embedded slots) and +// with a cached key silently. +// 5. deletePlaintextCloudBackups removes only *.db in the backup folder. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/backup/data/services/backup_service_encryption_test.dart` +Expected: FAIL — constructor params/methods missing. + +- [ ] **Step 3: Implement** + +In `_uploadToCloud`, replace the body's read-and-upload with: + +```dart + Future _uploadToCloud(String localPath, String filename) async { + if (_cloudProvider == null) return null; + + final folderId = await _getOrCreateCloudBackupFolder(); + if (folderId == null) return null; + + var uploadPath = localPath; + var uploadName = filename; + File? encryptedTemp; + if (_syncPreferences?.syncEncryptionEnabled ?? false) { + final key = await _encryptionKeyStore?.loadKey(); + final mirror = await _encryptionKeyStore?.loadKeyslotMirror(); + if (key == null || mirror == null) { + _log.error( + 'Encryption is enabled but no key/keyslots are available; ' + 'uploading backup UNENCRYPTED', + ); + } else { + final tempDir = await getTemporaryDirectory(); + uploadName = + '${p.basenameWithoutExtension(filename)}${BackupCrypto.fileExtension}'; + uploadPath = p.join(tempDir.path, uploadName); + await BackupCrypto.encryptFile( + inPath: localPath, + outPath: uploadPath, + mlk: key.mlk, + libraryKeyId: key.libraryKeyId, + keyslotBytes: mirror, + ); + encryptedTemp = File(uploadPath); + } + } + + try { + final bytes = await File(uploadPath).readAsBytes(); + final result = await _cloudProvider.uploadFile( + Uint8List.fromList(bytes), + uploadName, + folderId: folderId, + ); + return result.fileId; + } finally { + if (encryptedTemp != null && await encryptedTemp.exists()) { + try { + await encryptedTemp.delete(); + } catch (_) { + // best-effort temp cleanup + } + } + } + } +``` + +(Adjust the returned filename bookkeeping: find where the caller records `filename`/cloudFileId on the `BackupRecord` (~line 161 `cloudFileId = await _uploadToCloud(ref, filename)`) and make sure the record stores the `.sbe` name for cloud restores — change `_uploadToCloud` to return a `({String fileId, String cloudFilename})` record and update its two call/consume sites.) + +Restore seam in `restoreFromFile`, before extension validation: + +```dart + var effectivePath = filePath; + if (await BackupCrypto.isEncryptedBackup(filePath)) { + final tempDir = await getTemporaryDirectory(); + final decrypted = + p.join(tempDir.path, 'restore_${const Uuid().v4()}.db'); + final key = await _encryptionKeyStore?.loadKey(); + final artifactKeyId = await BackupCrypto.libraryKeyIdOf(filePath); + if (key != null && key.libraryKeyId == artifactKeyId) { + await BackupCrypto.decryptFileWithKey( + inPath: filePath, + outPath: decrypted, + mlk: key.mlk, + expectedLibraryKeyId: key.libraryKeyId, + ); + } else if (encryptionSecret != null) { + await BackupCrypto.decryptFile( + inPath: filePath, + outPath: decrypted, + secret: encryptionSecret, + ); + } else { + throw const BackupEncryptedException(); + } + effectivePath = decrypted; + } +``` + +...then use `effectivePath` for everything downstream (including the `.db`/`.sqlite` check, which the decrypted temp satisfies; separately extend the validation list to allow picking `.sbe` files at the UI layer in Task 13). Delete the decrypted temp in a `finally`. + +`deletePlaintextCloudBackups`: + +```dart + Future deletePlaintextCloudBackups() async { + final provider = _cloudProvider; + if (provider == null) return 0; + final folderId = await _getOrCreateCloudBackupFolder(); + if (folderId == null) return 0; + final files = await provider.listFiles( + folderId: folderId, + namePattern: 'submersion_backup_', + ); + var deleted = 0; + for (final f in files) { + if (f.name.startsWith('submersion_backup_') && f.name.endsWith('.db')) { + await provider.deleteFile(f.id); + deleted++; + } + } + return deleted; + } +``` + +- [ ] **Step 4: Run tests + the existing backup suite** + +Run: `flutter test test/features/backup/data/services/backup_service_encryption_test.dart test/features/backup/data/services/backup_service_test.dart` +Expected: all PASS (existing suite proves the null-deps default path is unchanged). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/backup/ test/features/backup/ +git commit -m "feat(backup): encrypted cloud backup upload, restore, plaintext cleanup (#520)" +``` + +--- + +### Task 11: End-to-end integration tests — leak invariant, encrypted convergence, join flow + +**Files:** +- Create: `test/core/services/sync/encrypted_sync_integration_test.dart` + +**Interfaces:** +- Consumes: everything from Tasks 1-8; `test/helpers/changeset_test_helpers.dart`, `test/helpers/sync_test_helpers.dart`, `test/core/services/sync/changeset_sync_convergence_test.dart` (read all three first and reuse their two-device scaffolding). +- Produces: regression armor only. + +- [ ] **Step 1: Write the no-plaintext-leak invariant test (fails until correct wiring)** + +The single most important test in the feature (spec section 9). Shape: + +```dart +test('after enable, every cloud file except keyslots is SBE1', () async { + // Arrange: one in-memory device DB with a dive + site seeded (reuse the + // convergence test's seeding), fake cloud, encryption enabled via + // SyncEncryptionService.enable(...) with fast KDF params, provider + // wrapped exactly as production wiring does (EncryptingCloudStorage- + // Provider around the fake), pendingReplace consumed by performSync(). + // Act: full performSync(); then a second sync after an edit (changeset + // path); then a backup with cloud upload. + // Assert over the fake provider's raw stored bytes: + final all = await fakeCloud.allStoredFiles(); // add helper if missing + for (final f in all) { + if (f.name == KeyslotFile.cloudFileName) { + expect(SyncEnvelope.hasMagic(f.bytes), isFalse); + continue; + } + expect(SyncEnvelope.hasMagic(f.bytes), isTrue, + reason: '${f.name} stored as plaintext'); + } + // Belt and braces: no known plaintext marker string anywhere: + for (final f in all.where((f) => f.name != KeyslotFile.cloudFileName)) { + expect(utf8.decode(f.bytes, allowMalformed: true), + isNot(contains('epochId'))); + } +}); +``` + +If `FakeCloudStorageProvider` lacks a raw-bytes enumeration, add `List<({String name, Uint8List bytes})> allStoredFiles()` to `test/support/fake_cloud_storage_provider.dart` (test support only). + +- [ ] **Step 2: Two-device encrypted convergence + join flow** + +In the same file, mirror `changeset_sync_convergence_test.dart`'s two-device setup with encryption on: + +```dart +// 1. Device A: enable (fast KDF) -> performSync (replace publishes +// encrypted base). Device B (raw provider, no key): performSync -> +// expect status awaitingPassphrase. +// 2. Device B: SyncEncryptionService.unlock(rawProvider, passphrase) -> +// rebuild B's provider wrapped with the unlocked key -> performSync -> +// adopt path -> assert B's DB contains A's dive (same assertion the +// plaintext convergence test makes). +// 3. Device A edits the dive -> sync; B syncs -> converged again +// (changeset path under encryption). +``` + +- [ ] **Step 3: Run to verify they fail, then fix wiring until green** + +Run: `flutter test test/core/services/sync/encrypted_sync_integration_test.dart` +Any failure here is a REAL wiring bug (this is the test that catches a seam bypassing the decorator). Investigate the failing file's write site rather than weakening an assertion. When the epoch-marker write turns out to bypass the decorated provider in tests (it must not — `writeLibraryEpochMarker` uses the injected provider), fix the test's wiring to match production wiring first. + +- [ ] **Step 4: Run the neighboring plaintext suites (no regression)** + +Run: `flutter test test/core/services/sync/changeset_sync_convergence_test.dart test/core/services/sync/sync_service_epoch_test.dart test/core/services/sync/sync_encryption_gate_test.dart` +Expected: all PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add test/ +git commit -m "test(sync): no-plaintext-leak invariant and encrypted two-device convergence (#520)" +``` + +--- + +### Task 12: Settings UI — encryption section + enable/manage dialogs + +**Files:** +- Create: `lib/features/settings/presentation/widgets/encryption_settings_section.dart` +- Create: `lib/features/settings/presentation/widgets/enable_encryption_dialog.dart` +- Create: `lib/features/settings/presentation/widgets/encryption_passphrase_dialog.dart` (shared prompt used for unlock / change / view-recovery re-auth) +- Modify: `lib/features/settings/presentation/pages/cloud_sync_page.dart` (mount the section) +- Modify: `lib/l10n/arb/app_en.arb` (EN strings only in this task) +- Test: `test/features/settings/presentation/widgets/encryption_settings_section_test.dart` + +**Interfaces:** +- Consumes: `encryptionKeyNotifierProvider`, `syncEncryptionServiceProvider`, `encryptionKeyStoreProvider`, `SyncPreferences.syncEncryptionEnabled`, `cloudStorageProviderProvider` (RAW handle: expose `cloudProviderInstanceFor(type)` for service calls that must bypass the decorator — unlock/enable read/write only exempt files, so either handle works; use the resolved provider), sync notifier trigger method (the same one the Sync Now button uses — find it on the cloud sync page), `BackupService.deletePlaintextCloudBackups` via its provider. +- Produces: user flows. States the section renders: + 1. Encryption off: "End-to-end encryption" tile + "Enable" button -> `EnableEncryptionDialog`. Enable is disabled (with an explanatory subtitle) when no cloud provider is configured — sync and cloud backups share the provider selection, so a configured provider is the precondition either way. A pending replace minted while auto-sync is off simply waits for the next manual/automatic sync; that is correct behavior, not a bug. + 2. On + unlocked: status tile ("Encryption on"), actions: Change passphrase, View recovery code (re-auth), Regenerate recovery code, Disable. + 3. On per prefs but LOCKED (no session): "Enter passphrase" action -> passphrase dialog -> `unlock` -> `setUnlocked` -> trigger sync. + +**Dialog flows (exact behavior):** +- `EnableEncryptionDialog` — a 3-step `Stepper` (or sequential pages in one dialog, matching Material 3 idiom used elsewhere in settings): + 1. Passphrase + confirm fields (obscured, min 8 chars enforced with an inline error), warning copy: other devices must update + will re-download; lost passphrase + recovery code = cloud data unrecoverable, local data safe. Checkbox "Delete existing unencrypted cloud backups" (default checked). + 2. Calls `enable(...)` (spinner), then shows the recovery code in a selectable monospace box + "I have saved my recovery code" checkbox gating the finish button. + 3. On finish: if the cleanup checkbox was checked call `deletePlaintextCloudBackups()`; trigger a sync (consumes the pending replace); pop. +- Disable: confirmation dialog explaining plaintext republish; on confirm — `disable(...)`, then `encryptionKeyNotifier.clear()` (drops the session so the provider rebuilds raw; the STORED key survives in `EncryptionKeyStore` for old encrypted backups — see Task 8's session-vs-key rule), then trigger sync (plaintext republish via pending replace), then `deleteCloudKeyslots(provider)`. +- Change passphrase / regenerate recovery / view recovery: prompt with `encryption_passphrase_dialog.dart`, call the corresponding service method, show result (new recovery code uses the same selectable box + confirm pattern). + +- [ ] **Step 1: Write the failing widget tests** + +Cover in `encryption_settings_section_test.dart` (use the repo's widget-test helpers — `test/helpers/test_app.dart`, l10n helpers; remember the FormSection gotchas memory: labels may render uppercased, `ensureVisible` before `tap`): + +```dart +// 1. Flag off -> section shows the Enable action. +// 2. Flag on + session -> shows Change passphrase / Disable actions. +// 3. Flag on + no session -> shows Enter passphrase action. +// 4. EnableEncryptionDialog: mismatched confirm shows inline error and +// does not call the service (override syncEncryptionServiceProvider +// with a mock/fake and assert no interaction). +// 5. EnableEncryptionDialog: happy path reaches the recovery-code step and +// the finish button stays disabled until the confirm checkbox is ticked. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/settings/presentation/widgets/encryption_settings_section_test.dart` +Expected: FAIL — widgets missing. + +- [ ] **Step 3: Implement widgets + mount + EN strings** + +Follow the cloud sync page's existing section pattern (read how the Behavior/Backend sections are built on `cloud_sync_page.dart` and mirror the container/heading style). Mount the section on `cloud_sync_page.dart` below the backend/provider section (locate a stable anchor: the widget that renders the sync Behavior toggles; insert the new section widget before it). All strings via l10n keys added to `app_en.arb` in this step, prefixed `syncEncryption*` (e.g. `syncEncryptionTitle`, `syncEncryptionEnable`, `syncEncryptionPassphrase`, `syncEncryptionPassphraseConfirm`, `syncEncryptionPassphraseMismatch`, `syncEncryptionRecoveryTitle`, `syncEncryptionRecoverySaved`, `syncEncryptionDeletePlaintextBackups`, `syncEncryptionWarnUpdateDevices`, `syncEncryptionWarnLoss`, `syncEncryptionChangePassphrase`, `syncEncryptionViewRecovery`, `syncEncryptionRegenerateRecovery`, `syncEncryptionDisable`, `syncEncryptionDisableWarn`, `syncEncryptionEnterPassphrase`, `syncEncryptionWrongPassphrase`, `syncEncryptionStatusOn`, `syncEncryptionStatusLocked`). Run `flutter gen-l10n` after editing the arb. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/features/settings/presentation/widgets/encryption_settings_section_test.dart` +Expected: all PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/settings/ lib/l10n/ test/features/settings/ +git commit -m "feat(settings): end-to-end encryption section with enable and manage flows (#520)" +``` + +--- + +### Task 13: Unlock banner, Troubleshoot row, restore prompt + +**Files:** +- Modify: `lib/features/settings/presentation/pages/cloud_sync_page.dart` (banner on `needsPassphrase`) +- Modify: `lib/features/settings/presentation/pages/troubleshoot_sync_page.dart` (status row + unlock action) +- Modify: the restore UI call sites of `restoreFromFile`/`restoreFromBackup` (find with `grep -rn "restoreFromFile\|restoreFromBackup" lib/features/backup/presentation/ lib/features/settings/`) to catch `BackupEncryptedException`, prompt with `encryption_passphrase_dialog.dart`, retry with `encryptionSecret` +- Modify: the restore file-picker extension filter (find with `grep -rn "sqlite'\|'db'" lib/features/backup/presentation/ | head`) to include `sbe` +- Modify: `lib/l10n/arb/app_en.arb` (banner/troubleshoot/restore strings) +- Test: `test/features/settings/presentation/pages/cloud_sync_page_unlock_banner_test.dart` + +**Interfaces:** +- Consumes: `SyncState.needsPassphrase` (Task 8), `SyncEncryptionService.unlock`, `EncryptionKeyNotifier.setUnlocked`, `WrongPassphraseException`, `BackupEncryptedException` (Task 10), passphrase dialog (Task 12). +- Produces: the join-flow UX. Banner mirrors the adopt banner's widget structure on the same page (find the widget keyed off `awaitingAdoption`/replace state and copy its layout): icon + "This library is encrypted" + "Enter passphrase" button; on success trigger sync; on `WrongPassphraseException` show the inline error and keep the dialog open. + +- [ ] **Step 1: Write the failing tests** + +`cloud_sync_page_unlock_banner_test.dart`: + +```dart +// 1. SyncState with needsPassphrase: true renders the unlock banner. +// 2. Tapping Enter passphrase opens the dialog; a fake service that +// throws WrongPassphraseException keeps the dialog open with the error +// text; a succeeding fake closes it and calls the sync trigger (assert +// via a probe/fake notifier). +``` + +Reuse the page's existing widget-test setup if one exists (`grep -rln "cloud_sync_page" test/ | head -3`); otherwise build the minimal harness with the standard test_app helpers and provider overrides. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/settings/presentation/pages/cloud_sync_page_unlock_banner_test.dart` +Expected: FAIL. + +- [ ] **Step 3: Implement banner + troubleshoot row + restore prompt** + +- Banner on `cloud_sync_page.dart` driven by `ref.watch(syncNotifierProvider).needsPassphrase` (match the real notifier/state names from Task 8). +- `troubleshoot_sync_page.dart`: add a row in its status list: label `syncEncryptionTitle`, value = Off / On / Locked (Locked when prefs flag true and session null, or last result was awaitingPassphrase); tapping when locked opens the same unlock dialog. Read the page first and follow its existing row/action pattern. +- Restore call sites: wrap in try/catch, on `BackupEncryptedException` prompt and retry once with the entered secret; surface `WrongPassphraseException` as the dialog's inline error. +- New EN strings: `syncEncryptionBannerTitle`, `syncEncryptionBannerBody`, `syncEncryptionUnlock`, `syncEncryptionRestorePromptTitle`, `syncEncryptionRestorePromptBody`. Run `flutter gen-l10n`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/features/settings/presentation/pages/cloud_sync_page_unlock_banner_test.dart test/features/settings` +Expected: all PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/ lib/l10n/ test/features/ +git commit -m "feat(sync): unlock banner, troubleshoot status, encrypted restore prompt (#520)" +``` + +--- + +### Task 14: Localization sweep + full verification + +**Files:** +- Modify: `lib/l10n/arb/app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb` +- Verify: whole project + +- [ ] **Step 1: Translate every new `syncEncryption*` key** + +List the new keys: `grep -o '"syncEncryption[A-Za-z]*"' lib/l10n/arb/app_en.arb | sort -u`. Translate each into all 10 locale files, preserving placeholder syntax exactly. Match each locale file's existing tone/formality (read a few neighboring sync strings in each file first). Then run `flutter gen-l10n`. + +- [ ] **Step 2: Verify no locale is missing a key** + +```bash +for f in lib/l10n/arb/app_{ar,de,es,fr,he,hu,it,nl,pt,zh}.arb; do + echo "== $f"; for k in $(grep -o '"syncEncryption[A-Za-z]*"' lib/l10n/arb/app_en.arb | sort -u); do + grep -q "$k" "$f" || echo "MISSING $k"; + done; done +``` + +Expected: no MISSING lines. + +- [ ] **Step 3: Whole-feature verification** + +```bash +dart format . +flutter analyze +flutter test test/core/services/sync/ test/core/services/cloud_storage/ \ + test/features/backup/ test/features/settings/ +``` + +Expected: format no changes, analyze clean, all listed suites green. + +- [ ] **Step 4: Spec cross-check** + +Re-read `docs/superpowers/specs/2026-07-10-encrypted-cloud-sync-design.md` section by section and confirm each requirement maps to shipped code/tests (sections 3-10). Fix anything missed BEFORE the final commit. + +- [ ] **Step 5: Commit** + +```bash +git add lib/l10n/ +git commit -m "l10n: translate sync encryption strings into all locales (#520)" +``` + +--- + +## Post-plan follow-ups (NOT tasks in this plan) + +- Device verification on real hardware (two devices + one legacy-version device against a real backend) before release — same bar the incremental-sync work used. +- `main.dart`: register `cryptography_flutter` platform acceleration (`FlutterCryptography.enable()`) — do this during device verification so any acceleration-specific behavior shows up on hardware, and only if profiling shows pure-Dart crypto is a bottleneck (measure first). +- GitHub wiki user-guide page + release notes. +- Reply on issue #520 (owner's call). diff --git a/docs/superpowers/specs/2026-07-10-encrypted-cloud-sync-design.md b/docs/superpowers/specs/2026-07-10-encrypted-cloud-sync-design.md new file mode 100644 index 000000000..6c5e7aa47 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-encrypted-cloud-sync-design.md @@ -0,0 +1,403 @@ +# Encrypted Cloud Sync and Cloud Backups (E2E) — Design + +Date: 2026-07-10 +Issue: https://github.com/submersion-app/submersion/issues/520 +Status: approved design, pre-implementation + +## 1. Summary + +Opt-in client-side encryption for everything Submersion writes to a cloud +provider: sync changesets, base snapshots, manifests, epoch/moved markers, and +cloud backup artifacts. A passphrase (plus a generated recovery code) protects +a random master key; the provider only ever stores opaque blobs. The sync +protocol keeps working unchanged because discovery is filename-based and all +merging is client-side. + +### Threat model + +- Protects against: the storage provider (Google, Dropbox, Apple, any S3 + operator) or anyone with access to the stored files reading logbook content + (GPS positions, buddy names, notes, certifications); a provider swapping or + replaying stored file contents undetected. +- Does not protect against: a compromised device, a weak passphrase under + offline brute force, or traffic/metadata analysis (file names, sizes, + timestamps, device counts remain visible — see Non-goals). + +### Relation to the abandoned 2026-06-01 attempt + +An earlier encrypted-sync feature (device-pairing model, ~77 commits, tip +`b1c008077e1`, still recoverable as dangling commits) targeted the old +full-snapshot transport and was abandoned. This design is fresh: the changeset +transport (PR #330) reduced the problem to encrypting discrete blobs behind a +single byte seam, and the passphrase model removes pairing entirely. Nothing +from the old branch is required. + +## 2. Decisions (settled with the user) + +1. Key model: wrapped master key. A random 256-bit master library key (MLK) + is wrapped by passphrase-derived keys in a keyslot file (restic/LUKS + pattern). Passphrase change rewraps one file; KDF parameters are + upgradable; multiple slots are native. +2. Scope: sync payloads and cloud backups in one release. Local backup + files (including pre-migration backups) stay plaintext. +3. Recovery: v1 ships with a recovery code (second keyslot) generated at + enable time. +4. Architecture: an encrypting decorator around `CloudStorageProvider`, so + every uploaded byte is ciphertext by construction. +5. Compression: gzip-before-encrypt inside the envelope, on by default for + encrypted uploads (encrypted bytes are incompressible downstream; sync + JSON shrinks 5-10x). + +## 3. Cryptographic design + +### 3.1 Key hierarchy + +``` +passphrase --Argon2id(salt_p, params)--> KEK_p --AES-256-GCM unwrap--+ + +--> MLK (32 random bytes) +recovery code --Argon2id(salt_r, params)--> KEK_r --AES-256-GCM unwrap--+ + | + data key = HKDF-SHA256(MLK, info="sbe:v1:data") +``` + +- MLK: generated once per encrypted library via `Random.secure()`; exists in + the cloud only wrapped. Identified by a random `libraryKeyId` (UUID). +- Argon2id parameters (initial): m=64 MiB, t=3, p=1. Parameters are stored + per slot in the keyslot file so they can be raised later by rewrapping. +- Wrong-passphrase detection is free: unwrap is AES-GCM, so a bad KEK fails + tag verification. No separate verifier field. +- HKDF sub-derivation gives future domain separation (e.g. a media key) + without touching keyslots. +- Recovery code: 8 words from the EFF short wordlist (1296 words, ~10.3 + bits/word, ~82 bits total), displayed hyphen-separated, normalized + case-insensitively with whitespace/hyphen stripping on entry. The wordlist + ships as an English constant (standard practice; not localized). + +### 3.2 Keyslot file (the only plaintext file) + +Cloud name: `submersion_keyslots.json`, in the sync folder. The name must not +contain the `submersion_sync` discovery stem (same rule as the epoch marker, +see `library_epoch.dart`). + +```json +{ + "version": 1, + "libraryKeyId": "", + "slots": [ + { "type": "passphrase", "salt": "", + "kdf": { "alg": "argon2id", "m": 65536, "t": 3, "p": 1 }, + "nonce": "", "wrapped": "" }, + { "type": "recovery", "salt": "", + "kdf": { "alg": "argon2id", "m": 65536, "t": 3, "p": 1 }, + "nonce": "", "wrapped": "" } + ] +} +``` + +Unlock tries the matching slot type first, then all slots. The file is small +(< 1 KB) and rewritten only on enable, passphrase change, or recovery-code +regeneration. + +### 3.3 Encrypted file envelope (single-shot; all sync files) + +``` +bytes 0-3 magic "SBE1" +bytes 4-19 libraryKeyId (16-byte UUID) +byte 20 flags (bit0 = payload gzipped before encryption) +bytes 21-32 nonce (12 bytes, random per file) +bytes 33- AES-256-GCM ciphertext || 16-byte tag +``` + +- Key: the HKDF data key. +- AAD: UTF-8 of the logical cloud filename. A ciphertext served under the + wrong name (provider bug or attack, e.g. replaying an old manifest as + current) fails authentication instead of parsing. +- Download-side name resolution: `downloadFile` takes only a file id, so the + decorator maintains an id-to-name map populated by its own `listFiles` / + `getFileInfo` passthroughs (every sync download follows a list through the + same instance — verified against all current call sites). On a cache miss + it fetches the name via `getFileInfo` before decrypting. +- Per-file random 96-bit nonces are safe at this scale (GCM birthday bound + needs ~2^32 encryptions; a library produces thousands). +- Legacy-client behavior falls out of the format: the binary envelope fails + `utf8.decode`/`jsonDecode`, which is exactly the shipped fail-closed path + (section 6). + +### 3.4 Framed envelope (cloud backups) + +Cloud name: `submersion_backup_.sbe`. + +``` +header: magic "SBE1" | libraryKeyId (16) | flags (bit1 = framed) + | uint32 keyslotBlockLen | keyslot block (same JSON as 3.2) +frames: uint32 len | nonce (12) | GCM(<= 8 MiB plaintext chunk) || tag + ... repeated; AAD = libraryKeyId || uint64 frameIndex || finalFlag +``` + +- Embedded keyslots make every backup self-decrypting with only the + passphrase or recovery code — restorable on a fresh device with no sync + configured, or from a file downloaded via the provider's web UI. Backups + made under an older passphrase remain restorable with that passphrase + (each artifact is a snapshot). +- 8 MiB frames bound crypto memory for multi-hundred-MB databases (sizing + reference: the 648 MB library from #358). Frame index + final flag in the + AAD prevent reordering and truncation. + +### 3.5 Algorithms and packages + +- AES-256-GCM, Argon2id, HKDF-SHA256 from `package:cryptography` (pure Dart, + all 5 platforms, no native build risk), with `cryptography_flutter` + registered so platform-accelerated implementations are used where + available. Wire bytes are identical either way (standard primitives). + Verify package maintenance status and current versions on pub.dev at + implementation time. +- gzip via `dart:io` `GZipCodec` (available on all supported platforms). +- Known-answer test vectors are computed with python3 at implementation + time, never from recall (standing project rule). +- Performance posture: measure before optimizing. No isolate offload up + front; parts encrypt independently in bounded memory. If profiling shows + jank on large publishes, offload per-part crypto then. + +## 4. Runtime architecture + +### 4.1 EncryptingCloudStorageProvider (decorator) + +`EncryptingCloudStorageProvider implements CloudStorageProvider`, wrapping the +configured provider at its single construction point when encryption is +enabled. When disabled, the raw provider is used (zero overhead, zero change). + +- `uploadFile(bytes, name)`: if `name` is exempt, pass through; else gzip + (flag bit0), encrypt with AAD=name, upload envelope. +- `downloadFile(id)`: fetch bytes; `SBE1` magic -> decrypt (resolving the + name for AAD from the file info) and gunzip per flags; no magic -> return + as-is (plaintext passthrough is required to read legacy folders and detect + foreign plaintext libraries). +- Exemption list (exactly two patterns): + 1. `submersion_keyslots.json` (it is the bootstrap key material). + 2. `submersion_backup_*` (the backup service applies the framed format + itself because it embeds keyslots). +- Everything else — `listFiles`, folders, auth, `getFileInfo` — delegates + untouched. File names are never encrypted. +- Failure typing: decryption problems raise distinct errors — + `SyncEncryptionRequired` (envelope present, no local MLK, or + `libraryKeyId` mismatch) vs `CloudStorageException` (auth-tag failure = + corruption/tamper). + +Consequences audited: + +- Streamed base parts already pass through `uploadFile`/`downloadFile` + per part (writer line ~102, reader line ~175), so streaming stays bounded. +- Manifest/changeset checksums are computed and verified above the seam, on + plaintext, on both sides — unchanged. GCM tags independently authenticate + ciphertext. +- `CloudFileInfo.sizeBytes` reflects ciphertext size. The only consumer that + cares is the compaction byte-ratio trigger, which compares like with like + (writer-recorded plaintext sizes); no change needed. + +### 4.2 Local key custody + +- MLK + `libraryKeyId` in `flutter_secure_storage` (Keychain / Keystore / + DPAPI / libsecret), reusing the existing `FallbackSecureStorage` pattern + from the S3 provider for the macOS `-34018` sandbox case. +- "Encryption enabled" flag + cached `libraryKeyId` in `SyncPreferences` + (SharedPreferences). Deliberately not in the `settings` table: that table + syncs, and these are device-local. +- No database schema migration is required. + +### 4.3 Unlock gate + +New `SyncResultStatus.awaitingPassphrase` (sibling of `awaitingAdoption`). +`performSync` catches `SyncEncryptionRequired` and halts with it; the Cloud +Sync page shows an "Enter passphrase" banner using the same interaction +pattern as the adopt banner. Unlock flow: derive KEK per slot, unwrap MLK +from the keyslot file, store in secure storage, re-run sync. + +## 5. Lifecycle flows + +Every flow rides existing, device-verified machinery; encryption adds one halt +state and one decorator, not new protocol. + +- Enable (device A): set passphrase (entry + confirm) -> display recovery + code with a confirm-saved step -> generate MLK -> upload keyslot file -> + run the existing library replace (`executeLibraryReplace`) through the + now-encrypting decorator. The replace already wipes all plaintext sync + files and republishes marker + base; they come out encrypted. The enable + dialog warns: all other devices must be updated and will re-download the + library; lost passphrase + lost recovery code = cloud data unrecoverable + (local data on devices is never at risk). Optional checkbox (default on): + delete existing unencrypted cloud backups now. +- Join (updated device B): next sync raises `SyncEncryptionRequired` -> + `awaitingPassphrase` banner -> passphrase entered -> unwrap MLK -> + re-sync: the encrypted epoch marker now decrypts, epoch differs from + accepted -> the existing adopt flow re-downloads the library. +- Legacy device C (older app): the encrypted epoch marker fails + `utf8.decode`/`jsonDecode`/`fromJson`, so `_runEpochGate` fails closed + ("Could not read the library epoch marker") until the app is updated. + Nothing is destroyed or leaked (see section 6). +- Disable: confirmation -> library replace back to plaintext -> delete the + keyslot file. The MLK stays in secure storage so previously made encrypted + backups remain restorable without re-prompting. +- Change passphrase: rewrap slot 1 (new salt + nonce) in the keyslot file. + Recovery code untouched; separate action regenerates it (re-auth with + passphrase first). Documented explicitly: this does not rotate the MLK — + full rotation = disable + enable. +- Conflicting re-enable: a device holding an MLK whose `libraryKeyId` does + not match the cloud's envelopes/keyslot treats it exactly like join + (re-prompt); copy explains the library's encryption was reset elsewhere. +- Backend switch: established-provider store and switch dialog unchanged. + Establishing an encrypted library on a new backend writes the keyslot file + as part of the first publish. Joining an encrypted backend via switch uses + the same unlock flow. +- Backups-only user (cloud backups configured, sync off): enable works the + same minus the library replace; no cloud keyslot file is required because + every backup artifact embeds its own slots. + +## 6. Legacy-client safety analysis (the migration keystone) + +Verified against current `sync_service.dart`: + +- `readLibraryEpochMarker` deliberately throws on unparseable content + ("unreadable must be distinguishable from absent") and + `LibraryEpochMarker.fromJson` throws `FormatException` without a plaintext + `epochId`. `_runEpochGate` catches and fails the sync closed. This is the + path every shipped client takes when it meets an encrypted marker. Safe. +- The path that had to be designed around: a readable (plaintext) marker + whose epoch has no readable ssv1 library sends old clients into + `_recoverUnreadableEpoch`, which after a 1-hour grace window wipes the + cloud folder and republishes plaintext from local + (`_reestablishEpochFromLocalLibrary`). Encrypting the epoch marker itself + is therefore mandatory, not optional — it converts every legacy device to + the fail-closed path above. The enable flow guarantees this by + construction: the replace rewrites the marker through the decorator. +- A pre-epoch plaintext folder cannot linger: enable always runs a replace, + which always writes an (encrypted) epoch marker. +- Regression pin: a unit test feeds an SBE1 envelope to + `readLibraryEpochMarker` and asserts it throws (section 9). + +## 7. Cloud backups + +- When encryption is on, `BackupService._uploadToCloud` encrypts the local + `.db` artifact file-to-file into the framed `.sbe` format (bounded + memory), uploads that, and records it. Local artifacts (default sandbox + dir, custom locations, SAF, pre-migration backups) remain plaintext `.db`: + backups are the disaster-recovery path and must never be locked behind a + passphrase; restoring plaintext backups keeps working regardless of + encryption state. +- Restore: the restore flow (picker and cloud list) gains one step — `.sbe` + extension or `SBE1` magic detected -> if a cached MLK matches the + artifact's `libraryKeyId`, decrypt silently; else prompt (accepts + passphrase or recovery code, tried against the embedded slots) -> decrypt + to a temp `.db` -> existing restore machinery unchanged. +- Upload memory: the whole-file read in `_uploadToCloud` is pre-existing + behavior; encryption does not worsen it (file-to-file crypto is streamed). + Streaming backup upload is a noted follow-up, out of scope. +- Retention/pruning: unchanged; encrypted artifacts prune by the same + records. Enable-time cleanup (checkbox) deletes existing plaintext cloud + backups immediately. + +## 8. Error handling + +| Situation | Behavior | +| --- | --- | +| Wrong passphrase at unlock or restore | GCM unwrap fails -> inline "incorrect passphrase", retry; both slot types tried | +| Envelope present, no local key | `awaitingPassphrase` halt + banner (never an exception toast) | +| `libraryKeyId` mismatch | Same banner; copy explains the library was re-encrypted elsewhere | +| Auth-tag failure (corrupt/tampered file) | Surfaced like existing checksum failures: reader stops, cursor does not advance, retry next sync | +| Keyslot file missing from cloud | Keyed devices unaffected (they never re-read it); next publish self-heals it (same mirror pattern as the epoch marker); new devices cannot join until then | +| Enable interrupted mid-replace | Inherits the replace flow's existing `pendingReplace` resume | +| iCloud conflicted copy of the keyslot file | Filtered by the existing conflict-copy name check; keyslot writes are rare (enable/rewrap only) | +| Troubleshoot Sync screen (#521) | Gains an encryption status row and an "Enter passphrase" action | + +## 9. Testing plan (TDD) + +- Known-answer tests: Argon2id, HKDF, AES-256-GCM, single-shot envelope, + framed envelope — vectors computed with python3 at implementation time. +- No-plaintext-leak invariant: full enable -> sync -> backup cycle against + `FakeCloudStorageProvider`; assert every stored file except + `submersion_keyslots.json` begins with `SBE1` and none contains a known + plaintext marker string. +- Two-device convergence with encryption on: reuse the existing fake-cloud + convergence harness wrapped in the decorator; include the join flow + (B halts `awaitingPassphrase` -> unlock -> adopt -> converge). +- Legacy fail-closed pin: encrypted marker -> `readLibraryEpochMarker` + throws -> gate halts. +- Decorator unit tests: passthrough when disabled, exemption list, AAD + binding (rename a ciphertext -> auth failure), gzip flag round-trip, + plaintext passthrough on missing magic. +- Keyslots: wrap/unwrap both slot types, wrong passphrase, slot rewrap + preserves MLK, KDF parameter upgrade path. +- Backup: `.sbe` round-trip; wrong passphrase; recovery-code redeem; + bit-flip tamper -> auth failure; truncated final frame -> error; fresh + device restore with no sync configured; old-passphrase artifact restores + with old passphrase. +- Lifecycle: enable / disable / change-passphrase / re-enable-with-new-key + (`libraryKeyId` mismatch), enable-time plaintext-backup cleanup. +- Recovery code: generation entropy source, normalization (case, + whitespace, hyphens), wordlist integrity (1296 entries). + +## 10. UI surface + +All new strings land in English plus the 10 non-English locales, then +`flutter gen-l10n` (standing project rule). + +- Settings -> Cloud Sync -> "End-to-end encryption" section: + - Enable: passphrase + confirm -> recovery code display + confirm-saved -> + warnings (other devices must update and re-download; lost passphrase + + code = cloud data unrecoverable, local data safe) -> optional delete of + existing plaintext cloud backups (default on). + - Change passphrase; Regenerate recovery code (re-auth with passphrase; + the code itself is never stored, so viewing an existing code is + impossible by design -- regeneration is the only honest action); + Disable (confirmation, explains plaintext republish). +- Cloud Sync page + Troubleshoot Sync screen: `awaitingPassphrase` banner + and action. +- Restore flow: passphrase/recovery prompt when an `.sbe` artifact is + selected. + +## 11. New components (one purpose each) + +| Unit | Purpose | +| --- | --- | +| `lib/core/services/sync/crypto/sync_envelope.dart` | Single-shot SBE1 envelope encode/decode (pure; bytes in, bytes out) | +| `lib/core/services/sync/crypto/keyslots.dart` | Keyslot file model, wrap/unwrap, slot management, KDF params | +| `lib/core/services/sync/crypto/recovery_code.dart` | EFF-wordlist generation + normalization | +| `lib/core/services/sync/crypto/encryption_key_store.dart` | Secure-storage custody of MLK + libraryKeyId; enabled flag via SyncPreferences | +| `lib/core/services/cloud_storage/encrypting_cloud_storage_provider.dart` | The decorator (policy table, gzip, error typing) | +| `lib/features/backup/data/services/backup_crypto.dart` | Framed `.sbe` file-to-file encrypt/decrypt with embedded keyslots | +| Settings/sync UI additions | Enable/manage section, unlock banner, restore prompt | + +Existing code touched (small, targeted): provider construction point (wrap), +`performSync` (catch -> `awaitingPassphrase`), `SyncResult`/status enum, +`BackupService` upload/restore seams, Troubleshoot screen, keyslot self-heal +in the publish path. + +## 12. Dependencies and risks + +- New packages: `cryptography` (+ `cryptography_flutter`). Verify + maintenance/versions at implementation; pin per repo conventions. Fallback + if unmaintained: `pointycastle` implementations behind the same seams. +- Pure-Dart crypto throughput on large bases is the main performance risk; + mitigated by platform delegates, bounded 8 MiB units, and the + measure-first rule before adding isolate offload. +- Argon2id memory parameter (64 MiB) on low-end Android devices: KDF runs + only at enable/unlock/restore (interactive moments). If profiling shows + problems, parameters are per-slot and can be tuned before release. +- User risk: passphrase loss. Mitigated by the recovery code, loud enable + warnings, and the fact that local data is never encrypted at rest. + +## 13. Non-goals (v1) + +- Filename/metadata hiding (device counts, sizes, timing remain visible). +- Media-file encryption (media does not sync yet; HKDF leaves room). +- A key-rotation button (disable + enable covers it). +- Per-provider keys, encrypting local backups, biometric-gated unlock + (the OS keystore already gates key access). +- Streaming cloud-backup upload (pre-existing whole-file behavior). + +## 14. Rollout notes + +- Release notes + GitHub wiki user-guide page (wiki is the user-doc source + of truth): enabling requires updating all devices first; recovery-code + guidance; what the provider can and cannot see. +- Respond on issue #520 with the design summary once the spec is approved + (owner's call; the reporter offered to contribute). diff --git a/lib/core/services/cloud_storage/encrypting_cloud_storage_provider.dart b/lib/core/services/cloud_storage/encrypting_cloud_storage_provider.dart new file mode 100644 index 000000000..849c0faf0 --- /dev/null +++ b/lib/core/services/cloud_storage/encrypting_cloud_storage_provider.dart @@ -0,0 +1,133 @@ +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + +/// Wraps the configured provider so every non-exempt byte stored in the +/// cloud is an SBE1 envelope. Filenames, listing, folders, and auth pass +/// through untouched; encryption is invisible to readers and writers above +/// this seam (spec section 4.1). +class EncryptingCloudStorageProvider implements CloudStorageProvider { + final CloudStorageProvider inner; + final SecretKey _dataKey; + final String _libraryKeyId; + + /// fileId -> filename, populated from list/info/upload results so + /// downloads know the AAD. Misses fall back to [getFileInfo]. + final Map _names = {}; + + EncryptingCloudStorageProvider( + this.inner, { + required SecretKey dataKey, + required String libraryKeyId, + }) : _dataKey = dataKey, + _libraryKeyId = libraryKeyId; + + /// Backup artifacts are self-framed (BackupCrypto's `.sbe` format) and + /// must pass through untouched on both upload and download. + static const String _backupPrefix = 'submersion_backup_'; + static const String _backupExtension = '.sbe'; + + /// Exactly two exemptions (spec 4.1): the keyslot bootstrap file and + /// framed backup artifacts, which carry their own encryption. Backups are + /// matched by prefix AND the `.sbe` extension, so a plaintext `.db` backup + /// is never mistaken for a self-framed one. + static bool isExempt(String filename) => + filename == KeyslotFile.cloudFileName || + (filename.startsWith(_backupPrefix) && + filename.endsWith(_backupExtension)); + + @override + Future uploadFile( + Uint8List data, + String filename, { + String? folderId, + }) async { + final bytes = isExempt(filename) + ? data + : await SyncEnvelope.seal( + plaintext: data, + dataKey: _dataKey, + libraryKeyId: _libraryKeyId, + filename: filename, + ); + final result = await inner.uploadFile(bytes, filename, folderId: folderId); + _names[result.fileId] = filename; + return result; + } + + @override + Future downloadFile(String fileId) async { + final bytes = await inner.downloadFile(fileId); + if (!SyncEnvelope.hasMagic(bytes)) return bytes; + final name = _names[fileId] ?? (await inner.getFileInfo(fileId))?.name; + // Self-framed artifacts (backups) share the SBE1 magic but are exempt: + // pass their bytes through untouched, symmetric with upload. Their own + // codec (BackupCrypto) owns decryption. Without this, restoring an + // encrypted cloud backup through the decorated provider would try to + // open the framed `.sbe` as a single-shot envelope and fail. + if (name != null && isExempt(name)) return bytes; + if (name == null) { + throw const EnvelopeCorruptException( + 'Encrypted file has no resolvable name for authentication', + ); + } + _names[fileId] = name; + return SyncEnvelope.open( + envelope: bytes, + dataKey: _dataKey, + expectedLibraryKeyId: _libraryKeyId, + filename: name, + ); + } + + @override + Future> listFiles({ + String? folderId, + String? namePattern, + }) async { + final files = await inner.listFiles( + folderId: folderId, + namePattern: namePattern, + ); + for (final f in files) { + _names[f.id] = f.name; + } + return files; + } + + @override + Future getFileInfo(String fileId) async { + final info = await inner.getFileInfo(fileId); + if (info != null) _names[info.id] = info.name; + return info; + } + + @override + String get providerName => inner.providerName; + @override + String get providerId => inner.providerId; + @override + Future isAvailable() => inner.isAvailable(); + @override + Future isAuthenticated() => inner.isAuthenticated(); + @override + Future authenticate() => inner.authenticate(); + @override + Future signOut() => inner.signOut(); + @override + Future getUserEmail() => inner.getUserEmail(); + @override + Future deleteFile(String fileId) => inner.deleteFile(fileId); + @override + Future fileExists(String fileId) => inner.fileExists(fileId); + @override + Future createFolder(String folderName, {String? parentFolderId}) => + inner.createFolder(folderName, parentFolderId: parentFolderId); + @override + Future getOrCreateSyncFolder() => inner.getOrCreateSyncFolder(); +} diff --git a/lib/core/services/sync/changeset_log/sync_manifest.dart b/lib/core/services/sync/changeset_log/sync_manifest.dart index ff6728727..601c48bc2 100644 --- a/lib/core/services/sync/changeset_log/sync_manifest.dart +++ b/lib/core/services/sync/changeset_log/sync_manifest.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + /// The per-device manifest: the small, rewritten-each-publish "commit point" /// that names the current base and changeset range. The only mutable file in /// a device's namespace. @@ -70,7 +73,17 @@ class SyncManifest { Uint8List toBytes() => Uint8List.fromList(utf8.encode(jsonEncode(toJson()))); - factory SyncManifest.fromBytes(Uint8List bytes) => SyncManifest.fromJson( - jsonDecode(utf8.decode(bytes)) as Map, - ); + factory SyncManifest.fromBytes(Uint8List bytes) { + // Defense in depth for encrypted libraries: never let an SBE1 envelope + // masquerade as a corrupt manifest (spec section 4.3). + if (SyncEnvelope.hasMagic(bytes)) { + throw SyncEncryptionRequired( + libraryKeyId: SyncEnvelope.libraryKeyIdOf(bytes), + message: 'Sync manifest is encrypted', + ); + } + return SyncManifest.fromJson( + jsonDecode(utf8.decode(bytes)) as Map, + ); + } } diff --git a/lib/core/services/sync/crypto/crypto_errors.dart b/lib/core/services/sync/crypto/crypto_errors.dart new file mode 100644 index 000000000..342a407df --- /dev/null +++ b/lib/core/services/sync/crypto/crypto_errors.dart @@ -0,0 +1,27 @@ +/// The cloud library is encrypted and this device holds no (matching) key. +/// +/// Carriers of this exception halt sync with +/// SyncResultStatus.awaitingPassphrase instead of surfacing a raw error. +class SyncEncryptionRequired implements Exception { + final String? libraryKeyId; + final String message; + + const SyncEncryptionRequired({ + this.libraryKeyId, + this.message = 'The cloud library is encrypted', + }); + + @override + String toString() => 'SyncEncryptionRequired($libraryKeyId): $message'; +} + +/// An SBE1 envelope failed structural parsing or authentication. Treated +/// like a checksum failure: transient-stop, never a silent fallback. +class EnvelopeCorruptException implements Exception { + final String message; + + const EnvelopeCorruptException(this.message); + + @override + String toString() => 'EnvelopeCorruptException: $message'; +} diff --git a/lib/core/services/sync/crypto/eff_short_wordlist.dart b/lib/core/services/sync/crypto/eff_short_wordlist.dart new file mode 100644 index 000000000..f29400e42 --- /dev/null +++ b/lib/core/services/sync/crypto/eff_short_wordlist.dart @@ -0,0 +1,1301 @@ +/// EFF short wordlist #1 (1296 words), for recovery codes. +/// Generated by scripts/generate_eff_wordlist.py. Do not edit. +/// Source: https://www.eff.org/dice +const List effShortWordlist = [ + 'acid', + 'acorn', + 'acre', + 'acts', + 'afar', + 'affix', + 'aged', + 'agent', + 'agile', + 'aging', + 'agony', + 'ahead', + 'aide', + 'aids', + 'aim', + 'ajar', + 'alarm', + 'alias', + 'alibi', + 'alien', + 'alike', + 'alive', + 'aloe', + 'aloft', + 'aloha', + 'alone', + 'amend', + 'amino', + 'ample', + 'amuse', + 'angel', + 'anger', + 'angle', + 'ankle', + 'apple', + 'april', + 'apron', + 'aqua', + 'area', + 'arena', + 'argue', + 'arise', + 'armed', + 'armor', + 'army', + 'aroma', + 'array', + 'arson', + 'art', + 'ashen', + 'ashes', + 'atlas', + 'atom', + 'attic', + 'audio', + 'avert', + 'avoid', + 'awake', + 'award', + 'awoke', + 'axis', + 'bacon', + 'badge', + 'bagel', + 'baggy', + 'baked', + 'baker', + 'balmy', + 'banjo', + 'barge', + 'barn', + 'bash', + 'basil', + 'bask', + 'batch', + 'bath', + 'baton', + 'bats', + 'blade', + 'blank', + 'blast', + 'blaze', + 'bleak', + 'blend', + 'bless', + 'blimp', + 'blink', + 'bloat', + 'blob', + 'blog', + 'blot', + 'blunt', + 'blurt', + 'blush', + 'boast', + 'boat', + 'body', + 'boil', + 'bok', + 'bolt', + 'boned', + 'boney', + 'bonus', + 'bony', + 'book', + 'booth', + 'boots', + 'boss', + 'botch', + 'both', + 'boxer', + 'breed', + 'bribe', + 'brick', + 'bride', + 'brim', + 'bring', + 'brink', + 'brisk', + 'broad', + 'broil', + 'broke', + 'brook', + 'broom', + 'brush', + 'buck', + 'bud', + 'buggy', + 'bulge', + 'bulk', + 'bully', + 'bunch', + 'bunny', + 'bunt', + 'bush', + 'bust', + 'busy', + 'buzz', + 'cable', + 'cache', + 'cadet', + 'cage', + 'cake', + 'calm', + 'cameo', + 'canal', + 'candy', + 'cane', + 'canon', + 'cape', + 'card', + 'cargo', + 'carol', + 'carry', + 'carve', + 'case', + 'cash', + 'cause', + 'cedar', + 'chain', + 'chair', + 'chant', + 'chaos', + 'charm', + 'chase', + 'cheek', + 'cheer', + 'chef', + 'chess', + 'chest', + 'chew', + 'chief', + 'chili', + 'chill', + 'chip', + 'chomp', + 'chop', + 'chow', + 'chuck', + 'chump', + 'chunk', + 'churn', + 'chute', + 'cider', + 'cinch', + 'city', + 'civic', + 'civil', + 'clad', + 'claim', + 'clamp', + 'clap', + 'clash', + 'clasp', + 'class', + 'claw', + 'clay', + 'clean', + 'clear', + 'cleat', + 'cleft', + 'clerk', + 'click', + 'cling', + 'clink', + 'clip', + 'cloak', + 'clock', + 'clone', + 'cloth', + 'cloud', + 'clump', + 'coach', + 'coast', + 'coat', + 'cod', + 'coil', + 'coke', + 'cola', + 'cold', + 'colt', + 'coma', + 'come', + 'comic', + 'comma', + 'cone', + 'cope', + 'copy', + 'coral', + 'cork', + 'cost', + 'cot', + 'couch', + 'cough', + 'cover', + 'cozy', + 'craft', + 'cramp', + 'crane', + 'crank', + 'crate', + 'crave', + 'crawl', + 'crazy', + 'creme', + 'crepe', + 'crept', + 'crib', + 'cried', + 'crisp', + 'crook', + 'crop', + 'cross', + 'crowd', + 'crown', + 'crumb', + 'crush', + 'crust', + 'cub', + 'cult', + 'cupid', + 'cure', + 'curl', + 'curry', + 'curse', + 'curve', + 'curvy', + 'cushy', + 'cut', + 'cycle', + 'dab', + 'dad', + 'daily', + 'dairy', + 'daisy', + 'dance', + 'dandy', + 'darn', + 'dart', + 'dash', + 'data', + 'date', + 'dawn', + 'deaf', + 'deal', + 'dean', + 'debit', + 'debt', + 'debug', + 'decaf', + 'decal', + 'decay', + 'deck', + 'decor', + 'decoy', + 'deed', + 'delay', + 'denim', + 'dense', + 'dent', + 'depth', + 'derby', + 'desk', + 'dial', + 'diary', + 'dice', + 'dig', + 'dill', + 'dime', + 'dimly', + 'diner', + 'dingy', + 'disco', + 'dish', + 'disk', + 'ditch', + 'ditzy', + 'dizzy', + 'dock', + 'dodge', + 'doing', + 'doll', + 'dome', + 'donor', + 'donut', + 'dose', + 'dot', + 'dove', + 'down', + 'dowry', + 'doze', + 'drab', + 'drama', + 'drank', + 'draw', + 'dress', + 'dried', + 'drift', + 'drill', + 'drive', + 'drone', + 'droop', + 'drove', + 'drown', + 'drum', + 'dry', + 'duck', + 'duct', + 'dude', + 'dug', + 'duke', + 'duo', + 'dusk', + 'dust', + 'duty', + 'dwarf', + 'dwell', + 'eagle', + 'early', + 'earth', + 'easel', + 'east', + 'eaten', + 'eats', + 'ebay', + 'ebony', + 'ebook', + 'echo', + 'edge', + 'eel', + 'eject', + 'elbow', + 'elder', + 'elf', + 'elk', + 'elm', + 'elope', + 'elude', + 'elves', + 'email', + 'emit', + 'empty', + 'emu', + 'enter', + 'entry', + 'envoy', + 'equal', + 'erase', + 'error', + 'erupt', + 'essay', + 'etch', + 'evade', + 'even', + 'evict', + 'evil', + 'evoke', + 'exact', + 'exit', + 'fable', + 'faced', + 'fact', + 'fade', + 'fall', + 'false', + 'fancy', + 'fang', + 'fax', + 'feast', + 'feed', + 'femur', + 'fence', + 'fend', + 'ferry', + 'fetal', + 'fetch', + 'fever', + 'fiber', + 'fifth', + 'fifty', + 'film', + 'filth', + 'final', + 'finch', + 'fit', + 'five', + 'flag', + 'flaky', + 'flame', + 'flap', + 'flask', + 'fled', + 'flick', + 'fling', + 'flint', + 'flip', + 'flirt', + 'float', + 'flock', + 'flop', + 'floss', + 'flyer', + 'foam', + 'foe', + 'fog', + 'foil', + 'folic', + 'folk', + 'food', + 'fool', + 'found', + 'fox', + 'foyer', + 'frail', + 'frame', + 'fray', + 'fresh', + 'fried', + 'frill', + 'frisk', + 'from', + 'front', + 'frost', + 'froth', + 'frown', + 'froze', + 'fruit', + 'gag', + 'gains', + 'gala', + 'game', + 'gap', + 'gas', + 'gave', + 'gear', + 'gecko', + 'geek', + 'gem', + 'genre', + 'gift', + 'gig', + 'gills', + 'given', + 'giver', + 'glad', + 'glass', + 'glide', + 'gloss', + 'glove', + 'glow', + 'glue', + 'goal', + 'going', + 'golf', + 'gong', + 'good', + 'gooey', + 'goofy', + 'gore', + 'gown', + 'grab', + 'grain', + 'grant', + 'grape', + 'graph', + 'grasp', + 'grass', + 'grave', + 'gravy', + 'gray', + 'green', + 'greet', + 'grew', + 'grid', + 'grief', + 'grill', + 'grip', + 'grit', + 'groom', + 'grope', + 'growl', + 'grub', + 'grunt', + 'guide', + 'gulf', + 'gulp', + 'gummy', + 'guru', + 'gush', + 'gut', + 'guy', + 'habit', + 'half', + 'halo', + 'halt', + 'happy', + 'harm', + 'hash', + 'hasty', + 'hatch', + 'hate', + 'haven', + 'hazel', + 'hazy', + 'heap', + 'heat', + 'heave', + 'hedge', + 'hefty', + 'help', + 'herbs', + 'hers', + 'hub', + 'hug', + 'hula', + 'hull', + 'human', + 'humid', + 'hump', + 'hung', + 'hunk', + 'hunt', + 'hurry', + 'hurt', + 'hush', + 'hut', + 'ice', + 'icing', + 'icon', + 'icy', + 'igloo', + 'image', + 'ion', + 'iron', + 'islam', + 'issue', + 'item', + 'ivory', + 'ivy', + 'jab', + 'jam', + 'jaws', + 'jazz', + 'jeep', + 'jelly', + 'jet', + 'jiffy', + 'job', + 'jog', + 'jolly', + 'jolt', + 'jot', + 'joy', + 'judge', + 'juice', + 'juicy', + 'july', + 'jumbo', + 'jump', + 'junky', + 'juror', + 'jury', + 'keep', + 'keg', + 'kept', + 'kick', + 'kilt', + 'king', + 'kite', + 'kitty', + 'kiwi', + 'knee', + 'knelt', + 'koala', + 'kung', + 'ladle', + 'lady', + 'lair', + 'lake', + 'lance', + 'land', + 'lapel', + 'large', + 'lash', + 'lasso', + 'last', + 'latch', + 'late', + 'lazy', + 'left', + 'legal', + 'lemon', + 'lend', + 'lens', + 'lent', + 'level', + 'lever', + 'lid', + 'life', + 'lift', + 'lilac', + 'lily', + 'limb', + 'limes', + 'line', + 'lint', + 'lion', + 'lip', + 'list', + 'lived', + 'liver', + 'lunar', + 'lunch', + 'lung', + 'lurch', + 'lure', + 'lurk', + 'lying', + 'lyric', + 'mace', + 'maker', + 'malt', + 'mama', + 'mango', + 'manor', + 'many', + 'map', + 'march', + 'mardi', + 'marry', + 'mash', + 'match', + 'mate', + 'math', + 'moan', + 'mocha', + 'moist', + 'mold', + 'mom', + 'moody', + 'mop', + 'morse', + 'most', + 'motor', + 'motto', + 'mount', + 'mouse', + 'mousy', + 'mouth', + 'move', + 'movie', + 'mower', + 'mud', + 'mug', + 'mulch', + 'mule', + 'mull', + 'mumbo', + 'mummy', + 'mural', + 'muse', + 'music', + 'musky', + 'mute', + 'nacho', + 'nag', + 'nail', + 'name', + 'nanny', + 'nap', + 'navy', + 'near', + 'neat', + 'neon', + 'nerd', + 'nest', + 'net', + 'next', + 'niece', + 'ninth', + 'nutty', + 'oak', + 'oasis', + 'oat', + 'ocean', + 'oil', + 'old', + 'olive', + 'omen', + 'onion', + 'only', + 'ooze', + 'opal', + 'open', + 'opera', + 'opt', + 'otter', + 'ouch', + 'ounce', + 'outer', + 'oval', + 'oven', + 'owl', + 'ozone', + 'pace', + 'pagan', + 'pager', + 'palm', + 'panda', + 'panic', + 'pants', + 'panty', + 'paper', + 'park', + 'party', + 'pasta', + 'patch', + 'path', + 'patio', + 'payer', + 'pecan', + 'penny', + 'pep', + 'perch', + 'perky', + 'perm', + 'pest', + 'petal', + 'petri', + 'petty', + 'photo', + 'plank', + 'plant', + 'plaza', + 'plead', + 'plot', + 'plow', + 'pluck', + 'plug', + 'plus', + 'poach', + 'pod', + 'poem', + 'poet', + 'pogo', + 'point', + 'poise', + 'poker', + 'polar', + 'polio', + 'polka', + 'polo', + 'pond', + 'pony', + 'poppy', + 'pork', + 'poser', + 'pouch', + 'pound', + 'pout', + 'power', + 'prank', + 'press', + 'print', + 'prior', + 'prism', + 'prize', + 'probe', + 'prong', + 'proof', + 'props', + 'prude', + 'prune', + 'pry', + 'pug', + 'pull', + 'pulp', + 'pulse', + 'puma', + 'punch', + 'punk', + 'pupil', + 'puppy', + 'purr', + 'purse', + 'push', + 'putt', + 'quack', + 'quake', + 'query', + 'quiet', + 'quill', + 'quilt', + 'quit', + 'quota', + 'quote', + 'rabid', + 'race', + 'rack', + 'radar', + 'radio', + 'raft', + 'rage', + 'raid', + 'rail', + 'rake', + 'rally', + 'ramp', + 'ranch', + 'range', + 'rank', + 'rant', + 'rash', + 'raven', + 'reach', + 'react', + 'ream', + 'rebel', + 'recap', + 'relax', + 'relay', + 'relic', + 'remix', + 'repay', + 'repel', + 'reply', + 'rerun', + 'reset', + 'rhyme', + 'rice', + 'rich', + 'ride', + 'rigid', + 'rigor', + 'rinse', + 'riot', + 'ripen', + 'rise', + 'risk', + 'ritzy', + 'rival', + 'river', + 'roast', + 'robe', + 'robin', + 'rock', + 'rogue', + 'roman', + 'romp', + 'rope', + 'rover', + 'royal', + 'ruby', + 'rug', + 'ruin', + 'rule', + 'runny', + 'rush', + 'rust', + 'rut', + 'sadly', + 'sage', + 'said', + 'saint', + 'salad', + 'salon', + 'salsa', + 'salt', + 'same', + 'sandy', + 'santa', + 'satin', + 'sauna', + 'saved', + 'savor', + 'sax', + 'say', + 'scale', + 'scam', + 'scan', + 'scare', + 'scarf', + 'scary', + 'scoff', + 'scold', + 'scoop', + 'scoot', + 'scope', + 'score', + 'scorn', + 'scout', + 'scowl', + 'scrap', + 'scrub', + 'scuba', + 'scuff', + 'sect', + 'sedan', + 'self', + 'send', + 'sepia', + 'serve', + 'set', + 'seven', + 'shack', + 'shade', + 'shady', + 'shaft', + 'shaky', + 'sham', + 'shape', + 'share', + 'sharp', + 'shed', + 'sheep', + 'sheet', + 'shelf', + 'shell', + 'shine', + 'shiny', + 'ship', + 'shirt', + 'shock', + 'shop', + 'shore', + 'shout', + 'shove', + 'shown', + 'showy', + 'shred', + 'shrug', + 'shun', + 'shush', + 'shut', + 'shy', + 'sift', + 'silk', + 'silly', + 'silo', + 'sip', + 'siren', + 'sixth', + 'size', + 'skate', + 'skew', + 'skid', + 'skier', + 'skies', + 'skip', + 'skirt', + 'skit', + 'sky', + 'slab', + 'slack', + 'slain', + 'slam', + 'slang', + 'slash', + 'slate', + 'slaw', + 'sled', + 'sleek', + 'sleep', + 'sleet', + 'slept', + 'slice', + 'slick', + 'slimy', + 'sling', + 'slip', + 'slit', + 'slob', + 'slot', + 'slug', + 'slum', + 'slurp', + 'slush', + 'small', + 'smash', + 'smell', + 'smile', + 'smirk', + 'smog', + 'snack', + 'snap', + 'snare', + 'snarl', + 'sneak', + 'sneer', + 'sniff', + 'snore', + 'snort', + 'snout', + 'snowy', + 'snub', + 'snuff', + 'speak', + 'speed', + 'spend', + 'spent', + 'spew', + 'spied', + 'spill', + 'spiny', + 'spoil', + 'spoke', + 'spoof', + 'spool', + 'spoon', + 'sport', + 'spot', + 'spout', + 'spray', + 'spree', + 'spur', + 'squad', + 'squat', + 'squid', + 'stack', + 'staff', + 'stage', + 'stain', + 'stall', + 'stamp', + 'stand', + 'stank', + 'stark', + 'start', + 'stash', + 'state', + 'stays', + 'steam', + 'steep', + 'stem', + 'step', + 'stew', + 'stick', + 'sting', + 'stir', + 'stock', + 'stole', + 'stomp', + 'stony', + 'stood', + 'stool', + 'stoop', + 'stop', + 'storm', + 'stout', + 'stove', + 'straw', + 'stray', + 'strut', + 'stuck', + 'stud', + 'stuff', + 'stump', + 'stung', + 'stunt', + 'suds', + 'sugar', + 'sulk', + 'surf', + 'sushi', + 'swab', + 'swan', + 'swarm', + 'sway', + 'swear', + 'sweat', + 'sweep', + 'swell', + 'swept', + 'swim', + 'swing', + 'swipe', + 'swirl', + 'swoop', + 'swore', + 'syrup', + 'tacky', + 'taco', + 'tag', + 'take', + 'tall', + 'talon', + 'tamer', + 'tank', + 'taper', + 'taps', + 'tarot', + 'tart', + 'task', + 'taste', + 'tasty', + 'taunt', + 'thank', + 'thaw', + 'theft', + 'theme', + 'thigh', + 'thing', + 'think', + 'thong', + 'thorn', + 'those', + 'throb', + 'thud', + 'thumb', + 'thump', + 'thus', + 'tiara', + 'tidal', + 'tidy', + 'tiger', + 'tile', + 'tilt', + 'tint', + 'tiny', + 'trace', + 'track', + 'trade', + 'train', + 'trait', + 'trap', + 'trash', + 'tray', + 'treat', + 'tree', + 'trek', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trio', + 'trout', + 'truce', + 'truck', + 'trump', + 'trunk', + 'try', + 'tug', + 'tulip', + 'tummy', + 'turf', + 'tusk', + 'tutor', + 'tutu', + 'tux', + 'tweak', + 'tweet', + 'twice', + 'twine', + 'twins', + 'twirl', + 'twist', + 'uncle', + 'uncut', + 'undo', + 'unify', + 'union', + 'unit', + 'untie', + 'upon', + 'upper', + 'urban', + 'used', + 'user', + 'usher', + 'utter', + 'value', + 'vapor', + 'vegan', + 'venue', + 'verse', + 'vest', + 'veto', + 'vice', + 'video', + 'view', + 'viral', + 'virus', + 'visa', + 'visor', + 'vixen', + 'vocal', + 'voice', + 'void', + 'volt', + 'voter', + 'vowel', + 'wad', + 'wafer', + 'wager', + 'wages', + 'wagon', + 'wake', + 'walk', + 'wand', + 'wasp', + 'watch', + 'water', + 'wavy', + 'wheat', + 'whiff', + 'whole', + 'whoop', + 'wick', + 'widen', + 'widow', + 'width', + 'wife', + 'wifi', + 'wilt', + 'wimp', + 'wind', + 'wing', + 'wink', + 'wipe', + 'wired', + 'wiry', + 'wise', + 'wish', + 'wispy', + 'wok', + 'wolf', + 'womb', + 'wool', + 'woozy', + 'word', + 'work', + 'worry', + 'wound', + 'woven', + 'wrath', + 'wreck', + 'wrist', + 'xerox', + 'yahoo', + 'yam', + 'yard', + 'year', + 'yeast', + 'yelp', + 'yield', + 'yo-yo', + 'yodel', + 'yoga', + 'yoyo', + 'yummy', + 'zebra', + 'zero', + 'zesty', + 'zippy', + 'zone', + 'zoom', +]; diff --git a/lib/core/services/sync/crypto/encryption_key_store.dart b/lib/core/services/sync/crypto/encryption_key_store.dart new file mode 100644 index 000000000..fd3c5864e --- /dev/null +++ b/lib/core/services/sync/crypto/encryption_key_store.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:submersion/core/services/secure_storage/fallback_secure_storage.dart'; + +/// The unlocked master key for the current encrypted library. +class UnlockedKey { + final String libraryKeyId; + final SecretKey mlk; + + const UnlockedKey({required this.libraryKeyId, required this.mlk}); +} + +/// Device-local custody of the master library key and a mirror of the last +/// keyslot file written to the cloud (needed for self-heal: the passphrase +/// is not retained, so the wrapped file cannot be regenerated locally). +class EncryptionKeyStore { + static const _keyIdKey = 'sync_encryption_library_key_id'; + static const _mlkKey = 'sync_encryption_mlk'; + static const _mirrorKey = 'sync_encryption_keyslot_mirror'; + + final FallbackSecureStorage _storage; + + EncryptionKeyStore({FlutterSecureStorage? storage}) + : _storage = FallbackSecureStorage(storage ?? const FlutterSecureStorage()); + + Future saveKey({ + required String libraryKeyId, + required List mlkBytes, + }) async { + await _storage.write(key: _keyIdKey, value: libraryKeyId); + await _storage.write(key: _mlkKey, value: base64Encode(mlkBytes)); + } + + Future loadKey() async { + final keyId = await _storage.read(key: _keyIdKey); + final mlk = await _storage.read(key: _mlkKey); + if (keyId == null || mlk == null) return null; + return UnlockedKey(libraryKeyId: keyId, mlk: SecretKey(base64Decode(mlk))); + } + + Future clearKey() async { + await _storage.delete(key: _keyIdKey); + await _storage.delete(key: _mlkKey); + } + + Future saveKeyslotMirror(Uint8List bytes) => + _storage.write(key: _mirrorKey, value: base64Encode(bytes)); + + Future loadKeyslotMirror() async { + final v = await _storage.read(key: _mirrorKey); + return v == null ? null : base64Decode(v); + } + + Future clearKeyslotMirror() => _storage.delete(key: _mirrorKey); +} diff --git a/lib/core/services/sync/crypto/keyslots.dart b/lib/core/services/sync/crypto/keyslots.dart new file mode 100644 index 000000000..4176a1f7d --- /dev/null +++ b/lib/core/services/sync/crypto/keyslots.dart @@ -0,0 +1,199 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; + +import 'package:submersion/core/services/sync/crypto/recovery_code.dart'; + +/// Argon2id parameters, stored per slot so they can be raised later by +/// rewrapping. Package unit note: `m` is in 1-KiB blocks (65536 = 64 MiB). +class KdfParams { + final String alg; + final int m; + final int t; + final int p; + + const KdfParams({ + this.alg = 'argon2id', + this.m = 65536, + this.t = 3, + this.p = 1, + }); + + Map toJson() => {'alg': alg, 'm': m, 't': t, 'p': p}; + + factory KdfParams.fromJson(Map json) => KdfParams( + alg: json['alg'] as String, + m: json['m'] as int, + t: json['t'] as int, + p: json['p'] as int, + ); +} + +/// One wrapped copy of the master library key. +class Keyslot { + final String type; // 'passphrase' | 'recovery' + final Uint8List salt; + final KdfParams kdf; + final Uint8List nonce; + final Uint8List wrapped; // AES-256-GCM(MLK) ciphertext||tag + + const Keyslot({ + required this.type, + required this.salt, + required this.kdf, + required this.nonce, + required this.wrapped, + }); + + Map toJson() => { + 'type': type, + 'salt': base64Encode(salt), + 'kdf': kdf.toJson(), + 'nonce': base64Encode(nonce), + 'wrapped': base64Encode(wrapped), + }; + + factory Keyslot.fromJson(Map json) => Keyslot( + type: json['type'] as String, + salt: base64Decode(json['salt'] as String), + kdf: KdfParams.fromJson(json['kdf'] as Map), + nonce: base64Decode(json['nonce'] as String), + wrapped: base64Decode(json['wrapped'] as String), + ); +} + +/// The one plaintext cloud file: wrapped keys plus their KDF parameters. +class KeyslotFile { + /// Must not contain the 'submersion_sync' discovery stem (same rule as + /// the epoch marker, see library_epoch.dart). + static const String cloudFileName = 'submersion_keyslots.json'; + + final int version; + final String libraryKeyId; + final List slots; + + const KeyslotFile({ + required this.version, + required this.libraryKeyId, + required this.slots, + }); + + Uint8List toJsonBytes() => Uint8List.fromList( + utf8.encode( + jsonEncode({ + 'version': version, + 'libraryKeyId': libraryKeyId, + 'slots': [for (final s in slots) s.toJson()], + }), + ), + ); + + factory KeyslotFile.fromJsonBytes(Uint8List bytes) { + final json = jsonDecode(utf8.decode(bytes)) as Map; + return KeyslotFile( + version: json['version'] as int, + libraryKeyId: json['libraryKeyId'] as String, + slots: [ + for (final s in json['slots'] as List) + Keyslot.fromJson(s as Map), + ], + ); + } + + /// Replace the slot sharing [slot]'s type, or append when absent. + KeyslotFile withReplacedSlot(Keyslot slot) => KeyslotFile( + version: version, + libraryKeyId: libraryKeyId, + slots: [ + for (final s in slots) + if (s.type != slot.type) s, + slot, + ], + ); +} + +abstract final class Keyslots { + static final AesGcm _aesGcm = AesGcm.with256bits(); + + static Future deriveKek({ + required String secret, + required Uint8List salt, + required KdfParams kdf, + }) { + final argon2id = Argon2id( + parallelism: kdf.p, + memory: kdf.m, + iterations: kdf.t, + hashLength: 32, + ); + return argon2id.deriveKeyFromPassword(password: secret, nonce: salt); + } + + static Future createSlot({ + required String type, + required String secret, + required SecretKey mlk, + KdfParams kdf = const KdfParams(), + Uint8List? saltForTest, + List? nonceForTest, + }) async { + final salt = saltForTest ?? _randomBytes(16); + final kek = await deriveKek(secret: secret, salt: salt, kdf: kdf); + final box = await _aesGcm.encrypt( + await mlk.extractBytes(), + secretKey: kek, + nonce: nonceForTest ?? _aesGcm.newNonce(), + ); + return Keyslot( + type: type, + salt: salt, + kdf: kdf, + nonce: Uint8List.fromList(box.nonce), + wrapped: Uint8List.fromList([...box.cipherText, ...box.mac.bytes]), + ); + } + + static Future tryUnwrap({ + required KeyslotFile file, + required String secret, + }) async { + for (final slot in file.slots) { + final candidate = slot.type == 'recovery' + ? RecoveryCode.normalize(secret) + : secret; + final kek = await deriveKek( + secret: candidate, + salt: slot.salt, + kdf: slot.kdf, + ); + final box = SecretBox( + Uint8List.sublistView(slot.wrapped, 0, slot.wrapped.length - 16), + nonce: slot.nonce, + mac: Mac(Uint8List.sublistView(slot.wrapped, slot.wrapped.length - 16)), + ); + try { + final mlkBytes = await _aesGcm.decrypt(box, secretKey: kek); + return SecretKey(mlkBytes); + } on SecretBoxAuthenticationError { + continue; + } + } + return null; + } + + static Future deriveDataKey(SecretKey mlk) async { + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + return hkdf.deriveKey( + secretKey: mlk, + nonce: const [], + info: utf8.encode('sbe:v1:data'), + ); + } + + static Uint8List _randomBytes(int n) { + final r = Random.secure(); + return Uint8List.fromList(List.generate(n, (_) => r.nextInt(256))); + } +} diff --git a/lib/core/services/sync/crypto/recovery_code.dart b/lib/core/services/sync/crypto/recovery_code.dart new file mode 100644 index 000000000..a555f91e6 --- /dev/null +++ b/lib/core/services/sync/crypto/recovery_code.dart @@ -0,0 +1,20 @@ +import 'dart:math'; + +import 'package:submersion/core/services/sync/crypto/eff_short_wordlist.dart'; + +/// 8 words from the EFF short wordlist: ~10.34 bits/word, ~82.7 bits total. +abstract final class RecoveryCode { + static const int wordCount = 8; + + static String generate() { + final random = Random.secure(); + return List.generate( + wordCount, + (_) => effShortWordlist[random.nextInt(effShortWordlist.length)], + ).join('-'); + } + + /// Lowercase; any run of whitespace/hyphens becomes a single hyphen. + static String normalize(String input) => + input.trim().toLowerCase().replaceAll(RegExp(r'[\s\-]+'), '-'); +} diff --git a/lib/core/services/sync/crypto/sync_encryption_service.dart b/lib/core/services/sync/crypto/sync_encryption_service.dart new file mode 100644 index 000000000..485a954eb --- /dev/null +++ b/lib/core/services/sync/crypto/sync_encryption_service.dart @@ -0,0 +1,275 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/recovery_code.dart'; +import 'package:submersion/core/services/sync/library_epoch.dart'; +import 'package:submersion/core/services/sync/library_epoch_store.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; + +/// No keyslot in the file authenticated against the entered secret. +class WrongPassphraseException implements Exception { + const WrongPassphraseException(); + + @override + String toString() => 'WrongPassphraseException'; +} + +class EnableEncryptionResult { + final String recoveryCode; + final String libraryKeyId; + + const EnableEncryptionResult({ + required this.recoveryCode, + required this.libraryKeyId, + }); +} + +/// Lifecycle operations for end-to-end encrypted sync: enable, unlock a +/// second device, rotate slots, disable, and keyslot self-heal. Transport +/// only -- callers sequence the surrounding sync/replace steps (the enable +/// and disable flows ride the existing pending-replace machinery). +class SyncEncryptionService { + static final _log = LoggerService.forClass(SyncEncryptionService); + + final EncryptionKeyStore _keyStore; + final SyncPreferences _preferences; + final Uuid _uuid = const Uuid(); + + SyncEncryptionService({ + required EncryptionKeyStore keyStore, + required SyncPreferences preferences, + }) : _keyStore = keyStore, + _preferences = preferences; + + /// Create the library key and keyslot file, persist local custody, flag + /// encryption on, and mint the pending library replace. The caller runs + /// the next sync, which consumes the pending replace through the + /// now-encrypting provider (resumable if interrupted). + Future enable({ + required CloudStorageProvider rawProvider, + required String passphrase, + required LibraryEpochStore epochStore, + required String deviceId, + String? deviceName, + String? appVersion, + KdfParams kdf = const KdfParams(), + }) async { + final mlkBytes = _randomBytes(32); + final mlk = SecretKey(mlkBytes); + final libraryKeyId = _uuid.v4(); + final recoveryCode = RecoveryCode.generate(); + final file = KeyslotFile( + version: 1, + libraryKeyId: libraryKeyId, + slots: [ + await Keyslots.createSlot( + type: 'passphrase', + secret: passphrase, + mlk: mlk, + kdf: kdf, + ), + await Keyslots.createSlot( + type: 'recovery', + secret: recoveryCode, + mlk: mlk, + kdf: kdf, + ), + ], + ); + final bytes = file.toJsonBytes(); + await _uploadKeyslots(rawProvider, bytes); + await _keyStore.saveKey(libraryKeyId: libraryKeyId, mlkBytes: mlkBytes); + await _keyStore.saveKeyslotMirror(bytes); + await _preferences.setSyncEncryptionEnabled(true); + await epochStore.setPendingReplace( + LibraryEpochMarker( + epochId: _uuid.v4(), + replacedAt: DateTime.now().millisecondsSinceEpoch, + deviceId: deviceId, + deviceName: deviceName, + appVersion: appVersion, + ), + ); + _log.info('Encryption enabled (key $libraryKeyId)'); + return EnableEncryptionResult( + recoveryCode: recoveryCode, + libraryKeyId: libraryKeyId, + ); + } + + /// Unlock this device against the cloud keyslot file with a passphrase or + /// recovery code. Persists key + mirror on success. + Future unlock({ + required CloudStorageProvider rawProvider, + required String secret, + }) async { + final fileBytes = await _downloadKeyslots(rawProvider); + if (fileBytes == null) { + throw const SyncEncryptionRequired( + message: 'No keyslot file found in the cloud', + ); + } + final keyslotFile = KeyslotFile.fromJsonBytes(fileBytes); + final mlk = await Keyslots.tryUnwrap(file: keyslotFile, secret: secret); + if (mlk == null) throw const WrongPassphraseException(); + await _keyStore.saveKey( + libraryKeyId: keyslotFile.libraryKeyId, + mlkBytes: await mlk.extractBytes(), + ); + await _keyStore.saveKeyslotMirror(fileBytes); + _log.info('Unlocked encrypted library ${keyslotFile.libraryKeyId}'); + return UnlockedKey(libraryKeyId: keyslotFile.libraryKeyId, mlk: mlk); + } + + /// Flag encryption off and mint the plaintext-republish replace. The + /// stored key is deliberately KEPT so previously made encrypted backups + /// still restore silently; the caller deletes the cloud keyslot file + /// after the plaintext republish succeeds. + Future disable({ + required LibraryEpochStore epochStore, + required String deviceId, + String? deviceName, + String? appVersion, + }) async { + await _preferences.setSyncEncryptionEnabled(false); + await epochStore.setPendingReplace( + LibraryEpochMarker( + epochId: _uuid.v4(), + replacedAt: DateTime.now().millisecondsSinceEpoch, + deviceId: deviceId, + deviceName: deviceName, + appVersion: appVersion, + ), + ); + _log.info('Encryption disabled; plaintext republish pending'); + } + + Future deleteCloudKeyslots(CloudStorageProvider rawProvider) async { + final match = await _findKeyslotFile(rawProvider); + if (match != null) { + await rawProvider.deleteFile(match.id); + } + } + + /// Rewrap the passphrase slot with a new passphrase. Recovery slot and + /// library key are untouched (this is NOT key rotation). + Future changePassphrase({ + required CloudStorageProvider rawProvider, + required String currentSecret, + required String newPassphrase, + KdfParams kdf = const KdfParams(), + }) async { + final (file, _) = await _unlockedKeyslots(rawProvider, currentSecret); + final mlk = await Keyslots.tryUnwrap(file: file, secret: currentSecret); + final updated = file.withReplacedSlot( + await Keyslots.createSlot( + type: 'passphrase', + secret: newPassphrase, + mlk: mlk!, + kdf: kdf, + ), + ); + final bytes = updated.toJsonBytes(); + await _uploadKeyslots(rawProvider, bytes); + await _keyStore.saveKeyslotMirror(bytes); + _log.info('Passphrase changed (slot rewrapped)'); + } + + /// Replace the recovery slot with a freshly generated code and return it. + Future regenerateRecoveryCode({ + required CloudStorageProvider rawProvider, + required String passphrase, + KdfParams kdf = const KdfParams(), + }) async { + final (file, mlk) = await _unlockedKeyslots(rawProvider, passphrase); + final code = RecoveryCode.generate(); + final updated = file.withReplacedSlot( + await Keyslots.createSlot( + type: 'recovery', + secret: code, + mlk: mlk, + kdf: kdf, + ), + ); + final bytes = updated.toJsonBytes(); + await _uploadKeyslots(rawProvider, bytes); + await _keyStore.saveKeyslotMirror(bytes); + _log.info('Recovery code regenerated'); + return code; + } + + /// Re-upload the mirrored keyslot file when the cloud copy is missing + /// (same self-heal pattern as the epoch marker). No-op unless encryption + /// is enabled and a mirror exists. + Future selfHealKeyslots(CloudStorageProvider rawProvider) async { + if (!_preferences.syncEncryptionEnabled) return; + final mirror = await _keyStore.loadKeyslotMirror(); + if (mirror == null) return; + if (await _findKeyslotFile(rawProvider) != null) return; + _log.warning('Cloud keyslot file missing; re-uploading the mirror'); + await _uploadKeyslots(rawProvider, mirror); + } + + Future<(KeyslotFile, SecretKey)> _unlockedKeyslots( + CloudStorageProvider rawProvider, + String secret, + ) async { + var bytes = await _downloadKeyslots(rawProvider); + bytes ??= await _keyStore.loadKeyslotMirror(); + if (bytes == null) { + throw const SyncEncryptionRequired( + message: 'No keyslot file found in the cloud', + ); + } + final file = KeyslotFile.fromJsonBytes(bytes); + final mlk = await Keyslots.tryUnwrap(file: file, secret: secret); + if (mlk == null) throw const WrongPassphraseException(); + return (file, mlk); + } + + Future _findKeyslotFile(CloudStorageProvider provider) async { + final files = await provider.listFiles( + namePattern: KeyslotFile.cloudFileName, + ); + for (final f in files) { + if (f.name == KeyslotFile.cloudFileName) return f; + } + return null; + } + + Future _downloadKeyslots(CloudStorageProvider provider) async { + final match = await _findKeyslotFile(provider); + if (match == null) return null; + return provider.downloadFile(match.id); + } + + Future _uploadKeyslots( + CloudStorageProvider provider, + Uint8List bytes, + ) async { + String? folderId; + try { + folderId = await provider.getOrCreateSyncFolder(); + } catch (_) { + folderId = null; + } + await provider.uploadFile( + bytes, + KeyslotFile.cloudFileName, + folderId: folderId, + ); + } + + static Uint8List _randomBytes(int n) { + final r = Random.secure(); + return Uint8List.fromList(List.generate(n, (_) => r.nextInt(256))); + } +} diff --git a/lib/core/services/sync/crypto/sync_envelope.dart b/lib/core/services/sync/crypto/sync_envelope.dart new file mode 100644 index 000000000..2a8d04dfa --- /dev/null +++ b/lib/core/services/sync/crypto/sync_envelope.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:uuid/uuid_value.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; + +/// Single-shot SBE1 envelope: the byte form of every encrypted sync file. +/// +/// Layout (spec 3.3): "SBE1"(4) | libraryKeyId(16) | flags(1) | nonce(12) +/// | AES-256-GCM ciphertext || 16-byte tag. AAD = UTF-8 logical filename. +/// flags bit0 = payload was gzipped before encryption. +abstract final class SyncEnvelope { + static const List magic = [0x53, 0x42, 0x45, 0x31]; // "SBE1" + static const int _headerLength = 4 + 16 + 1 + 12; + static const int _flagGzip = 0x01; + + static final AesGcm _aesGcm = AesGcm.with256bits(); + + static bool hasMagic(List bytes) => + bytes.length >= magic.length && + bytes[0] == magic[0] && + bytes[1] == magic[1] && + bytes[2] == magic[2] && + bytes[3] == magic[3]; + + /// The UUID string at header offset 4, without decrypting. + static String libraryKeyIdOf(Uint8List bytes) { + if (!hasMagic(bytes) || bytes.length < _headerLength) { + throw const EnvelopeCorruptException('Not an SBE1 envelope'); + } + return UuidValue.fromByteList(Uint8List.sublistView(bytes, 4, 20)).uuid; + } + + static Future seal({ + required Uint8List plaintext, + required SecretKey dataKey, + required String libraryKeyId, + required String filename, + bool compress = true, + List? nonceForTest, + }) async { + var payload = plaintext; + var flags = 0; + if (compress) { + final gz = Uint8List.fromList(gzip.encode(plaintext)); + if (gz.length < plaintext.length) { + payload = gz; + flags |= _flagGzip; + } + } + final nonce = nonceForTest ?? _aesGcm.newNonce(); + final box = await _aesGcm.encrypt( + payload, + secretKey: dataKey, + nonce: nonce, + aad: utf8.encode(filename), + ); + final keyIdBytes = UuidValue.withValidation(libraryKeyId).toBytes(); + final out = BytesBuilder(copy: false) + ..add(magic) + ..add(keyIdBytes) + ..addByte(flags) + ..add(box.nonce) + ..add(box.cipherText) + ..add(box.mac.bytes); + return out.takeBytes(); + } + + static Future open({ + required Uint8List envelope, + required SecretKey dataKey, + required String expectedLibraryKeyId, + required String filename, + }) async { + if (!hasMagic(envelope) || envelope.length < _headerLength + 16) { + throw const EnvelopeCorruptException('Not an SBE1 envelope'); + } + final keyId = libraryKeyIdOf(envelope); + if (keyId != expectedLibraryKeyId.toLowerCase()) { + throw SyncEncryptionRequired( + libraryKeyId: keyId, + message: 'File is encrypted under a different library key', + ); + } + final flags = envelope[20]; + final nonce = Uint8List.sublistView(envelope, 21, 21 + 12); + final body = Uint8List.sublistView(envelope, _headerLength); + final box = SecretBox( + Uint8List.sublistView(body, 0, body.length - 16), + nonce: nonce, + mac: Mac(Uint8List.sublistView(body, body.length - 16)), + ); + final List payload; + try { + payload = await _aesGcm.decrypt( + box, + secretKey: dataKey, + aad: utf8.encode(filename), + ); + } on SecretBoxAuthenticationError { + throw const EnvelopeCorruptException( + 'Envelope failed authentication (corrupt, tampered, or wrong name)', + ); + } + if ((flags & _flagGzip) != 0) { + return Uint8List.fromList(gzip.decode(payload)); + } + return Uint8List.fromList(payload); + } +} diff --git a/lib/core/services/sync/sync_preferences.dart b/lib/core/services/sync/sync_preferences.dart index c00ee6bb2..f5fb1446e 100644 --- a/lib/core/services/sync/sync_preferences.dart +++ b/lib/core/services/sync/sync_preferences.dart @@ -5,6 +5,7 @@ class SyncPreferences { static const String _autoSyncKey = 'sync_auto_enabled'; static const String _syncOnLaunchKey = 'sync_on_launch'; static const String _syncOnResumeKey = 'sync_on_resume'; + static const String _encryptionEnabledKey = 'sync_encryption_enabled'; final SharedPreferences _prefs; @@ -25,4 +26,11 @@ class SyncPreferences { Future setSyncOnResume(bool value) async { await _prefs.setBool(_syncOnResumeKey, value); } + + bool get syncEncryptionEnabled => + _prefs.getBool(_encryptionEnabledKey) ?? false; + + Future setSyncEncryptionEnabled(bool value) async { + await _prefs.setBool(_encryptionEnabledKey, value); + } } diff --git a/lib/core/services/sync/sync_service.dart b/lib/core/services/sync/sync_service.dart index 2daef74a5..af9d53312 100644 --- a/lib/core/services/sync/sync_service.dart +++ b/lib/core/services/sync/sync_service.dart @@ -25,6 +25,9 @@ import 'package:submersion/core/services/sync/changeset_log/sync_manifest.dart'; import 'package:submersion/core/services/sync/hlc.dart'; import 'package:submersion/core/services/sync/library_epoch.dart'; import 'package:submersion/core/services/sync/library_epoch_store.dart'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; import 'package:submersion/core/services/sync/library_moved.dart'; import 'package:submersion/core/services/sync/sync_clock.dart'; import 'package:submersion/core/services/sync/sync_data_serializer.dart'; @@ -39,6 +42,7 @@ enum SyncResultStatus { networkError, authError, awaitingAdoption, + awaitingPassphrase, error, } @@ -170,6 +174,10 @@ class SyncService { /// Library epoch persistence (restore Replace mode). Nullable so existing /// constructions keep working; epoch gating activates only when provided. final LibraryEpochStore? _epochStore; + + /// Keyslot self-heal after publish (encrypted libraries). Nullable so + /// existing constructions keep working; heal runs only when provided. + final SyncEncryptionService? _encryptionService; final _log = LoggerService.forClass(SyncService); final _uuid = const Uuid(); @@ -216,11 +224,13 @@ class SyncService { CloudStorageProvider? cloudProvider, SyncInitializer? syncInitializer, LibraryEpochStore? epochStore, + SyncEncryptionService? encryptionService, }) : _syncRepository = syncRepository, _serializer = serializer, _cloudProvider = cloudProvider, _syncInitializer = syncInitializer, - _epochStore = epochStore; + _epochStore = epochStore, + _encryptionService = encryptionService; /// Set a callback to receive progress updates during sync void setProgressCallback(SyncProgressCallback? callback) { @@ -481,6 +491,18 @@ class SyncService { } } + // ---- Keyslot self-heal (encrypted libraries) ---- + // Keyslot list/upload use an exempt name, so the decorated provider is + // equivalent to the raw one for this call. Never fatal to the sync. + final enc = _encryptionService; + if (enc != null) { + try { + await enc.selfHealKeyslots(provider); + } catch (e) { + _log.warning('Keyslot self-heal failed (non-fatal): $e'); + } + } + // Advance state only on a clean apply (idempotent re-pull otherwise). final now = DateTime.now(); if (recordsFailed == 0) { @@ -528,6 +550,12 @@ class SyncService { status: SyncResultStatus.networkError, message: 'Sync timed out', ); + } on SyncEncryptionRequired catch (e) { + _log.info('Sync halted: encrypted library requires a passphrase'); + return SyncResult( + status: SyncResultStatus.awaitingPassphrase, + message: e.message, + ); } on CloudStorageException catch (e) { return SyncResult( status: SyncResultStatus.networkError, @@ -559,6 +587,10 @@ class SyncService { LibraryEpochMarker? marker; try { marker = await readLibraryEpochMarker(provider); + } on SyncEncryptionRequired { + // Not unreadable-corrupt: encrypted. Reaches performSync's handler, + // which halts with awaitingPassphrase instead of a generic error. + rethrow; } catch (e) { _log.warning('Library epoch marker unreadable; failing closed: $e'); return const _EpochGate.halt( @@ -2706,6 +2738,14 @@ class SyncService { final bytes = await provider .downloadFile(candidates.first.id) .timeout(const Duration(seconds: 30)); + if (SyncEnvelope.hasMagic(bytes)) { + // Encrypted library and this device has no (matching) key: distinct + // from corruption so the caller can prompt for the passphrase. + throw SyncEncryptionRequired( + libraryKeyId: SyncEnvelope.libraryKeyIdOf(bytes), + message: 'The library epoch marker is encrypted', + ); + } final decoded = jsonDecode(utf8.decode(bytes)); if (decoded is! Map) { throw const FormatException('Library epoch marker is not a JSON object'); diff --git a/lib/features/backup/data/services/backup_crypto.dart b/lib/features/backup/data/services/backup_crypto.dart new file mode 100644 index 000000000..afd5a0254 --- /dev/null +++ b/lib/features/backup/data/services/backup_crypto.dart @@ -0,0 +1,237 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:uuid/uuid_value.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + +/// Framed, self-decrypting encrypted backup artifact (spec 3.4). +/// +/// Layout: "SBE1"(4) | libraryKeyId(16) | flags(1, bit1 = framed) +/// | uint32 BE keyslotBlockLen | keyslot JSON (same shape as the cloud +/// keyslot file), then frames of uint32 BE bodyLen | nonce(12) | +/// AES-256-GCM ciphertext || tag(16). AAD per frame = keyId(16) || +/// uint64 BE frameIndex || finalFlag(1), so frames cannot be reordered, +/// substituted, or truncated undetected. Embedded keyslots make every +/// artifact restorable with just the passphrase or recovery code. +abstract final class BackupCrypto { + static const String fileExtension = '.sbe'; + static const int frameSize = 8 * 1024 * 1024; + static const int _flagFramed = 0x02; + static const int _headerFixedLength = 4 + 16 + 1 + 4; + + static final AesGcm _aesGcm = AesGcm.with256bits(); + + static Future encryptFile({ + required String inPath, + required String outPath, + required SecretKey mlk, + required String libraryKeyId, + required Uint8List keyslotBytes, + }) async { + final dataKey = await Keyslots.deriveDataKey(mlk); + final keyIdBytes = UuidValue.withValidation(libraryKeyId).toBytes(); + final input = File(inPath).openSync(); + final out = File(outPath).openSync(mode: FileMode.write); + try { + out.writeFromSync(SyncEnvelope.magic); + out.writeFromSync(keyIdBytes); + out.writeByteSync(_flagFramed); + out.writeFromSync(_uint32be(keyslotBytes.length)); + out.writeFromSync(keyslotBytes); + final total = input.lengthSync(); + var offset = 0; + var frameIndex = 0; + do { + final n = (total - offset).clamp(0, frameSize); + final chunk = input.readSync(n); + offset += n; + final isFinal = offset >= total; + final box = await _aesGcm.encrypt( + chunk, + secretKey: dataKey, + aad: _frameAad(keyIdBytes, frameIndex, isFinal), + ); + final body = BytesBuilder(copy: false) + ..add(box.nonce) + ..add(box.cipherText) + ..add(box.mac.bytes); + final bodyBytes = body.takeBytes(); + out.writeFromSync(_uint32be(bodyBytes.length)); + out.writeFromSync(bodyBytes); + frameIndex++; + } while (offset < total); + } finally { + input.closeSync(); + out.closeSync(); + } + } + + /// Decrypt using the artifact's embedded keyslots and a passphrase or + /// recovery code. Throws [WrongPassphraseException] when no slot opens. + static Future decryptFile({ + required String inPath, + required String outPath, + required String secret, + }) async { + final input = File(inPath).openSync(); + try { + final header = _readHeader(input); + final keyslotFile = KeyslotFile.fromJsonBytes(header.keyslotBytes); + final mlk = await Keyslots.tryUnwrap(file: keyslotFile, secret: secret); + if (mlk == null) throw const WrongPassphraseException(); + final dataKey = await Keyslots.deriveDataKey(mlk); + await _decryptFrames(input, outPath, dataKey, header.keyIdBytes); + } finally { + input.closeSync(); + } + } + + /// Decrypt silently with an already-unlocked key. Throws + /// [SyncEncryptionRequired] when the artifact's key differs. + static Future decryptFileWithKey({ + required String inPath, + required String outPath, + required SecretKey mlk, + required String expectedLibraryKeyId, + }) async { + final input = File(inPath).openSync(); + try { + final header = _readHeader(input); + final artifactKeyId = UuidValue.fromByteList(header.keyIdBytes).uuid; + if (artifactKeyId != expectedLibraryKeyId.toLowerCase()) { + throw SyncEncryptionRequired( + libraryKeyId: artifactKeyId, + message: 'Backup is encrypted under a different library key', + ); + } + final dataKey = await Keyslots.deriveDataKey(mlk); + await _decryptFrames(input, outPath, dataKey, header.keyIdBytes); + } finally { + input.closeSync(); + } + } + + static Future isEncryptedBackup(String path) async { + final file = File(path); + if (!await file.exists()) return false; + final raf = file.openSync(); + try { + if (raf.lengthSync() < _headerFixedLength) return false; + final head = raf.readSync(4); + return SyncEnvelope.hasMagic(head); + } finally { + raf.closeSync(); + } + } + + static Future libraryKeyIdOf(String path) async { + final raf = File(path).openSync(); + try { + final header = _readHeader(raf); + return UuidValue.fromByteList(header.keyIdBytes).uuid; + } finally { + raf.closeSync(); + } + } + + static ({Uint8List keyIdBytes, Uint8List keyslotBytes}) _readHeader( + RandomAccessFile input, + ) { + input.setPositionSync(0); + if (input.lengthSync() < _headerFixedLength) { + throw const EnvelopeCorruptException('Not an encrypted backup'); + } + final fixed = input.readSync(_headerFixedLength); + if (!SyncEnvelope.hasMagic(fixed)) { + throw const EnvelopeCorruptException('Not an encrypted backup'); + } + final keyIdBytes = Uint8List.sublistView(fixed, 4, 20); + final flags = fixed[20]; + if ((flags & _flagFramed) == 0) { + throw const EnvelopeCorruptException('Backup is not a framed envelope'); + } + final slotLen = ByteData.sublistView(fixed, 21, 25).getUint32(0); + if (input.lengthSync() < _headerFixedLength + slotLen) { + throw const EnvelopeCorruptException('Backup keyslot block truncated'); + } + final keyslotBytes = input.readSync(slotLen); + return ( + keyIdBytes: Uint8List.fromList(keyIdBytes), + keyslotBytes: keyslotBytes, + ); + } + + static Future _decryptFrames( + RandomAccessFile input, + String outPath, + SecretKey dataKey, + Uint8List keyIdBytes, + ) async { + final out = File(outPath).openSync(mode: FileMode.write); + try { + final total = input.lengthSync(); + var frameIndex = 0; + var sawFinal = false; + while (true) { + final pos = input.positionSync(); + if (pos >= total) break; + if (sawFinal) { + throw const EnvelopeCorruptException( + 'Backup has data after the final frame', + ); + } + final lenBytes = input.readSync(4); + if (lenBytes.length < 4) { + throw const EnvelopeCorruptException('Backup frame header truncated'); + } + final bodyLen = ByteData.sublistView(lenBytes).getUint32(0); + final body = input.readSync(bodyLen); + if (body.length < bodyLen || bodyLen < 12 + 16) { + throw const EnvelopeCorruptException('Backup frame truncated'); + } + final isFinal = input.positionSync() >= total; + final box = SecretBox( + Uint8List.sublistView(body, 12, body.length - 16), + nonce: Uint8List.sublistView(body, 0, 12), + mac: Mac(Uint8List.sublistView(body, body.length - 16)), + ); + final List plain; + try { + plain = await _aesGcm.decrypt( + box, + secretKey: dataKey, + aad: _frameAad(keyIdBytes, frameIndex, isFinal), + ); + } on SecretBoxAuthenticationError { + throw const EnvelopeCorruptException( + 'Backup frame failed authentication ' + '(corrupt, tampered, reordered, or truncated)', + ); + } + out.writeFromSync(plain); + if (isFinal) sawFinal = true; + frameIndex++; + } + if (!sawFinal) { + throw const EnvelopeCorruptException( + 'Backup truncated: final frame missing', + ); + } + } finally { + out.closeSync(); + } + } + + static List _frameAad(List keyId16, int index, bool isFinal) { + final b = ByteData(8)..setUint64(0, index); + return [...keyId16, ...b.buffer.asUint8List(), isFinal ? 1 : 0]; + } + + static Uint8List _uint32be(int v) => + Uint8List(4)..buffer.asByteData().setUint32(0, v); +} diff --git a/lib/features/backup/data/services/backup_service.dart b/lib/features/backup/data/services/backup_service.dart index 988359ded..acd314eeb 100644 --- a/lib/features/backup/data/services/backup_service.dart +++ b/lib/features/backup/data/services/backup_service.dart @@ -12,9 +12,13 @@ import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/services/backup_bookmark_service.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; import 'package:submersion/core/services/sync/library_epoch.dart'; import 'package:submersion/core/services/sync/library_epoch_store.dart'; import 'package:submersion/core/services/sync/post_restore_sync_store.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; +import 'package:submersion/features/backup/data/services/backup_crypto.dart'; +import 'package:submersion/features/backup/domain/exceptions/backup_encrypted_exception.dart'; import 'package:submersion/features/backup/data/repositories/backup_preferences.dart'; import 'package:submersion/features/backup/data/services/backup_database_adapter.dart'; import 'package:submersion/features/backup/data/services/backup_saf_port.dart'; @@ -82,6 +86,13 @@ class BackupService { /// Nullable so existing constructions keep working. final PostRestoreSyncStore? _postRestoreSyncStore; + /// End-to-end encryption deps: when the sync-encryption flag is on and a + /// key + keyslot mirror are available, cloud uploads become framed .sbe + /// artifacts and encrypted restores can decrypt silently. Both nullable so + /// existing constructions keep working (plaintext behavior). + final EncryptionKeyStore? _encryptionKeyStore; + final SyncPreferences? _syncPreferences; + /// SAF write/read/delete seam for Android custom (`content://`) backup /// locations. Defaults to the real platform channel; injectable for tests. final BackupSafPort _safPort; @@ -99,13 +110,17 @@ class BackupService { LibraryEpochStore? epochStore, PostRestoreSyncStore? postRestoreSyncStore, BackupSafPort? safPort, + EncryptionKeyStore? encryptionKeyStore, + SyncPreferences? syncPreferences, }) : _dbAdapter = dbAdapter, _preferences = preferences, _cloudProvider = cloudProvider, _syncRepository = syncRepository ?? SyncRepository(), _epochStore = epochStore, _postRestoreSyncStore = postRestoreSyncStore, - _safPort = safPort ?? const MethodChannelBackupSafPort(); + _safPort = safPort ?? const MethodChannelBackupSafPort(), + _encryptionKeyStore = encryptionKeyStore, + _syncPreferences = syncPreferences; // =========================================================================== // Backup @@ -260,9 +275,22 @@ class BackupService { // Check extension final ext = p.extension(filePath).toLowerCase(); - if (ext != '.sqlite' && ext != '.db') { + if (ext != '.sqlite' && ext != '.db' && ext != BackupCrypto.fileExtension) { return BackupValidationResult.invalid( - 'Invalid file extension "$ext". Expected .db or .sqlite', + 'Invalid file extension "$ext". Expected .db, .sqlite, or ' + '${BackupCrypto.fileExtension}', + ); + } + + // An encrypted artifact cannot be opened as SQLite here; the magic check + // admits it and deep validation runs on the decrypted copy during + // restore (see restoreFromFile / restoreFromBackup). + if (await BackupCrypto.isEncryptedBackup(filePath)) { + return BackupValidationResult.valid(sizeBytes: await file.length()); + } + if (ext == BackupCrypto.fileExtension) { + return const BackupValidationResult.invalid( + 'File is not a valid encrypted backup', ); } @@ -317,6 +345,7 @@ class BackupService { Future restoreFromBackup( BackupRecord record, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { _log.info('Starting restore from: ${record.filename} (mode: $mode)'); @@ -350,19 +379,29 @@ class BackupService { throw const BackupException('Backup file not found locally or in cloud'); } - // Parity with the file-picker path: the file on disk (or the fresh - // download) may have been corrupted since the record was written. - final validation = await validateBackupFile(sourcePath); - if (!validation.isValid) { - throw BackupException( - validation.error ?? 'Backup file failed validation', - ); - } + // An encrypted cloud artifact must be decrypted BEFORE validation (the + // SQLite deep-check needs plaintext). + final materialized = await _materializePlaintextBackup( + sourcePath, + encryptionSecret: encryptionSecret, + ); + try { + // Parity with the file-picker path: the file on disk (or the fresh + // download) may have been corrupted since the record was written. + final validation = await validateBackupFile(materialized.path); + if (!validation.isValid) { + throw BackupException( + validation.error ?? 'Backup file failed validation', + ); + } - // Restore using DatabaseService (handles close/copy/reinitialize), then - // re-baseline sync so the restored data syncs cleanly instead of replaying - // the backup's stale sync position. - await _replaceDatabaseAndRebaselineSync(sourcePath); + // Restore using DatabaseService (handles close/copy/reinitialize), then + // re-baseline sync so the restored data syncs cleanly instead of + // replaying the backup's stale sync position. + await _replaceDatabaseAndRebaselineSync(materialized.path); + } finally { + await materialized.cleanUp(); + } if (mode == RestoreMode.replace) { await _mintPendingReplace(); } else { @@ -384,6 +423,7 @@ class BackupService { Future restoreFromFile( String filePath, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { _log.info('Starting restore from file: $filePath (mode: $mode)'); @@ -392,13 +432,23 @@ class BackupService { throw const BackupException('Backup file not found'); } - // Create a proper backup before restoring so the user can find it - // in their configured backup location and in the history list. - await performBackup(); + // Decrypt an encrypted artifact BEFORE the pre-restore backup runs, so a + // wrong passphrase aborts the flow without side effects. + final materialized = await _materializePlaintextBackup( + filePath, + encryptionSecret: encryptionSecret, + ); + try { + // Create a proper backup before restoring so the user can find it + // in their configured backup location and in the history list. + await performBackup(); - // Restore using DatabaseService, then re-baseline sync (see - // _replaceDatabaseAndRebaselineSync). - await _replaceDatabaseAndRebaselineSync(filePath); + // Restore using DatabaseService, then re-baseline sync (see + // _replaceDatabaseAndRebaselineSync). + await _replaceDatabaseAndRebaselineSync(materialized.path); + } finally { + await materialized.cleanUp(); + } if (mode == RestoreMode.replace) { await _mintPendingReplace(); } else { @@ -846,13 +896,112 @@ class BackupService { final folderId = await _getOrCreateCloudBackupFolder(); if (folderId == null) return null; - final bytes = await File(localPath).readAsBytes(); - final result = await _cloudProvider.uploadFile( - Uint8List.fromList(bytes), - filename, + // End-to-end encryption: the CLOUD copy becomes a framed .sbe artifact + // (local artifacts stay plaintext by design -- disaster recovery must + // never sit behind a passphrase). File-to-file crypto keeps memory + // bounded regardless of database size. + var uploadPath = localPath; + var uploadName = filename; + File? encryptedTemp; + if (_syncPreferences?.syncEncryptionEnabled ?? false) { + final key = await _encryptionKeyStore?.loadKey(); + final mirror = await _encryptionKeyStore?.loadKeyslotMirror(); + if (key == null || mirror == null) { + _log.error( + 'Encryption is enabled but no key/keyslots are available; ' + 'uploading backup UNENCRYPTED', + ); + } else { + final tempDir = await getTemporaryDirectory(); + uploadName = + p.basenameWithoutExtension(filename) + BackupCrypto.fileExtension; + uploadPath = p.join(tempDir.path, uploadName); + await BackupCrypto.encryptFile( + inPath: localPath, + outPath: uploadPath, + mlk: key.mlk, + libraryKeyId: key.libraryKeyId, + keyslotBytes: mirror, + ); + encryptedTemp = File(uploadPath); + } + } + + try { + final bytes = await File(uploadPath).readAsBytes(); + final result = await _cloudProvider.uploadFile( + Uint8List.fromList(bytes), + uploadName, + folderId: folderId, + ); + return result.fileId; + } finally { + if (encryptedTemp != null && await encryptedTemp.exists()) { + try { + await encryptedTemp.delete(); + } catch (_) { + // best-effort temp cleanup + } + } + } + } + + /// When [sourcePath] is an encrypted (.sbe framed) artifact, decrypt it to + /// a temp .db: silently with the cached key when the artifact's keyId + /// matches, else with [encryptionSecret] (passphrase or recovery code) + /// against the embedded keyslots. Throws [BackupEncryptedException] when + /// neither is available. Plaintext sources pass through untouched. + Future<_MaterializedBackup> _materializePlaintextBackup( + String sourcePath, { + String? encryptionSecret, + }) async { + if (!await BackupCrypto.isEncryptedBackup(sourcePath)) { + return _MaterializedBackup(sourcePath, isTemp: false); + } + final tempDir = await getTemporaryDirectory(); + final decrypted = p.join(tempDir.path, 'restore_${_uuid.v4()}.db'); + final key = await _encryptionKeyStore?.loadKey(); + final artifactKeyId = await BackupCrypto.libraryKeyIdOf(sourcePath); + if (key != null && key.libraryKeyId == artifactKeyId) { + await BackupCrypto.decryptFileWithKey( + inPath: sourcePath, + outPath: decrypted, + mlk: key.mlk, + expectedLibraryKeyId: key.libraryKeyId, + ); + } else if (encryptionSecret != null) { + await BackupCrypto.decryptFile( + inPath: sourcePath, + outPath: decrypted, + secret: encryptionSecret, + ); + } else { + throw const BackupEncryptedException(); + } + return _MaterializedBackup(decrypted, isTemp: true); + } + + /// Delete every UNENCRYPTED backup (`submersion_backup_*.db`) from the + /// cloud backup folder. Offered at encryption-enable time; encrypted .sbe + /// artifacts are left alone. Returns the number deleted. + Future deletePlaintextCloudBackups() async { + final provider = _cloudProvider; + if (provider == null) return 0; + final folderId = await _getOrCreateCloudBackupFolder(); + if (folderId == null) return 0; + final files = await provider.listFiles( folderId: folderId, + namePattern: 'submersion_backup_', ); - return result.fileId; + var deleted = 0; + for (final f in files) { + if (f.name.startsWith('submersion_backup_') && f.name.endsWith('.db')) { + await provider.deleteFile(f.id); + deleted++; + } + } + _log.info('Deleted $deleted unencrypted cloud backups'); + return deleted; } Future _downloadFromCloud(String cloudFileId, String filename) async { @@ -894,6 +1043,25 @@ class BackupException implements Exception { } /// Result of validating a backup file +/// A restore source resolved to a plaintext path: either the original file +/// (pass-through) or a decrypted temp copy that [cleanUp] removes. +class _MaterializedBackup { + final String path; + final bool isTemp; + + const _MaterializedBackup(this.path, {required this.isTemp}); + + Future cleanUp() async { + if (!isTemp) return; + try { + final f = File(path); + if (await f.exists()) await f.delete(); + } catch (_) { + // best-effort temp cleanup + } + } +} + class BackupValidationResult { final bool isValid; final String? error; diff --git a/lib/features/backup/domain/exceptions/backup_encrypted_exception.dart b/lib/features/backup/domain/exceptions/backup_encrypted_exception.dart new file mode 100644 index 000000000..e12133c09 --- /dev/null +++ b/lib/features/backup/domain/exceptions/backup_encrypted_exception.dart @@ -0,0 +1,9 @@ +/// The selected backup artifact is encrypted (SBE1 framed) and no cached +/// key matches, so a passphrase or recovery code is required. The restore +/// UI catches this, prompts, and retries with `encryptionSecret`. +class BackupEncryptedException implements Exception { + const BackupEncryptedException(); + + @override + String toString() => 'BackupEncryptedException: backup requires a passphrase'; +} diff --git a/lib/features/backup/presentation/pages/backup_settings_page.dart b/lib/features/backup/presentation/pages/backup_settings_page.dart index 160c819e4..7d708e510 100644 --- a/lib/features/backup/presentation/pages/backup_settings_page.dart +++ b/lib/features/backup/presentation/pages/backup_settings_page.dart @@ -12,11 +12,13 @@ import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.da import 'package:submersion/features/backup/domain/entities/backup_record.dart'; import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; import 'package:submersion/features/backup/presentation/pages/restore_complete_page.dart'; +import 'package:submersion/features/backup/domain/exceptions/backup_encrypted_exception.dart'; import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; import 'package:submersion/features/backup/presentation/widgets/backup_history_tile.dart'; import 'package:submersion/features/backup/presentation/widgets/export_bottom_sheet.dart'; import 'package:submersion/features/backup/presentation/widgets/restore_confirmation_dialog.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/encryption_passphrase_dialog.dart'; import 'package:submersion/l10n/l10n_extension.dart'; class BackupSettingsPage extends ConsumerWidget { @@ -247,9 +249,25 @@ class BackupSettingsPage extends ConsumerWidget { offerReplace: ref.read(cloudStorageProviderProvider) != null, ); if (mode != null) { - ref - .read(backupOperationProvider.notifier) - .restoreFromFilePath(filePath, mode: mode); + try { + await ref + .read(backupOperationProvider.notifier) + .restoreFromFilePath(filePath, mode: mode); + } on BackupEncryptedException { + if (!context.mounted) return; + await showEncryptionPassphraseDialog( + context, + title: context.l10n.settings_cloudSync_encryption_unlockTitle, + hint: context.l10n.settings_cloudSync_encryption_unlockHint, + onSubmit: (secret) => ref + .read(backupOperationProvider.notifier) + .restoreFromFilePath( + filePath, + mode: mode, + encryptionSecret: secret, + ), + ); + } } } @@ -366,9 +384,25 @@ class BackupSettingsPage extends ConsumerWidget { offerReplace: ref.read(cloudStorageProviderProvider) != null, ); if (mode != null) { - ref - .read(backupOperationProvider.notifier) - .restoreFromBackup(record, mode: mode); + try { + await ref + .read(backupOperationProvider.notifier) + .restoreFromBackup(record, mode: mode); + } on BackupEncryptedException { + if (!context.mounted) return; + await showEncryptionPassphraseDialog( + context, + title: context.l10n.settings_cloudSync_encryption_unlockTitle, + hint: context.l10n.settings_cloudSync_encryption_unlockHint, + onSubmit: (secret) => ref + .read(backupOperationProvider.notifier) + .restoreFromBackup( + record, + mode: mode, + encryptionSecret: secret, + ), + ); + } } case 'delete': final confirmed = await _showDeleteConfirmation(context); diff --git a/lib/features/backup/presentation/providers/backup_providers.dart b/lib/features/backup/presentation/providers/backup_providers.dart index 1294b7ba1..6d6edbe1d 100644 --- a/lib/features/backup/presentation/providers/backup_providers.dart +++ b/lib/features/backup/presentation/providers/backup_providers.dart @@ -8,6 +8,9 @@ import 'package:submersion/core/services/sync/post_restore_sync_store.dart'; import 'package:submersion/features/backup/data/repositories/backup_preferences.dart'; import 'package:submersion/features/backup/data/services/backup_service.dart'; import 'package:submersion/features/backup/domain/entities/backup_record.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart' + show WrongPassphraseException; +import 'package:submersion/features/backup/domain/exceptions/backup_encrypted_exception.dart'; import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; import 'package:submersion/features/backup/domain/entities/restore_mode.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; @@ -33,6 +36,8 @@ final backupServiceProvider = Provider((ref) { postRestoreSyncStore: PostRestoreSyncStore( ref.watch(sharedPreferencesProvider), ), + encryptionKeyStore: ref.watch(encryptionKeyStoreProvider), + syncPreferences: ref.watch(syncPreferencesProvider), ); }); @@ -211,6 +216,7 @@ class BackupOperationNotifier extends StateNotifier { Future restoreFromBackup( BackupRecord record, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { if (state.status == BackupOperationStatus.inProgress) return; @@ -220,12 +226,26 @@ class BackupOperationNotifier extends StateNotifier { ); try { - await _service.restoreFromBackup(record, mode: mode); + await _service.restoreFromBackup( + record, + mode: mode, + encryptionSecret: encryptionSecret, + ); await _syncActiveDiverAfterRestore(); state = const BackupOperationState( status: BackupOperationStatus.restoreComplete, ); _ref.invalidate(backupHistoryProvider); + } on BackupEncryptedException { + // The page prompts for the passphrase and retries with the secret. + state = const BackupOperationState(status: BackupOperationStatus.idle); + rethrow; + } on WrongPassphraseException { + // A wrong secret during the retry must reach the passphrase dialog so it + // can keep itself open and show the inline error; the generic catch below + // would swallow it into an error state and close the dialog on success. + state = const BackupOperationState(status: BackupOperationStatus.idle); + rethrow; } catch (e) { state = BackupOperationState( status: BackupOperationStatus.error, @@ -313,6 +333,7 @@ class BackupOperationNotifier extends StateNotifier { Future restoreFromFilePath( String filePath, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { if (state.status == BackupOperationStatus.inProgress) return; @@ -337,12 +358,26 @@ class BackupOperationNotifier extends StateNotifier { message: 'Restoring backup...', ); - await _service.restoreFromFile(filePath, mode: mode); + await _service.restoreFromFile( + filePath, + mode: mode, + encryptionSecret: encryptionSecret, + ); await _syncActiveDiverAfterRestore(); state = const BackupOperationState( status: BackupOperationStatus.restoreComplete, ); _ref.invalidate(backupHistoryProvider); + } on BackupEncryptedException { + // The page prompts for the passphrase and retries with the secret. + state = const BackupOperationState(status: BackupOperationStatus.idle); + rethrow; + } on WrongPassphraseException { + // A wrong secret during the retry must reach the passphrase dialog so it + // can keep itself open and show the inline error; the generic catch below + // would swallow it into an error state and close the dialog on success. + state = const BackupOperationState(status: BackupOperationStatus.idle); + rethrow; } catch (e) { state = BackupOperationState( status: BackupOperationStatus.error, diff --git a/lib/features/settings/presentation/pages/cloud_sync_page.dart b/lib/features/settings/presentation/pages/cloud_sync_page.dart index a0f56b72e..ceb8ad5da 100644 --- a/lib/features/settings/presentation/pages/cloud_sync_page.dart +++ b/lib/features/settings/presentation/pages/cloud_sync_page.dart @@ -17,6 +17,7 @@ import 'package:submersion/features/settings/presentation/pages/troubleshoot_syn import 'package:submersion/features/settings/presentation/widgets/adopt_replaced_library_dialog.dart'; import 'package:submersion/features/settings/presentation/widgets/conflict_resolution_dialog.dart'; import 'package:submersion/features/settings/presentation/widgets/dropbox_connect_dialog.dart'; +import 'package:submersion/features/settings/presentation/widgets/encryption_settings_section.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -94,6 +95,8 @@ class CloudSyncPage extends ConsumerWidget { isCustomFolderMode, ), const Divider(), + const EncryptionSettingsSection(), + const Divider(), _buildAdvancedSection(context, ref), ], ), @@ -912,6 +915,49 @@ class CloudSyncPage extends ConsumerWidget { ), ), ), + if (syncState.needsPassphrase) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Card( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.lock_outline, + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + context + .l10n + .settings_cloudSync_encryption_statusLockedSubtitle, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () => runEncryptionUnlockFlow(context, ref), + child: Text( + context + .l10n + .settings_cloudSync_encryption_enterPassphrase, + ), + ), + ], + ), + ), + ), + ), if (syncState.firstSyncAwaitingConfirmation) Padding( padding: const EdgeInsets.only(bottom: 12), diff --git a/lib/features/settings/presentation/pages/troubleshoot_sync_page.dart b/lib/features/settings/presentation/pages/troubleshoot_sync_page.dart index 228777833..4f74db434 100644 --- a/lib/features/settings/presentation/pages/troubleshoot_sync_page.dart +++ b/lib/features/settings/presentation/pages/troubleshoot_sync_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/encryption_settings_section.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; /// Recovery actions for a wedged Cloud Sync state (issue #509). Reached from /// the Cloud Sync page's Advanced section and by tapping the sync-error banner. @@ -15,6 +17,7 @@ class TroubleshootSyncPage extends ConsumerWidget { appBar: AppBar(title: const Text('Troubleshoot Sync')), body: ListView( children: [ + _buildEncryptionStatusRow(context, ref), ListTile( leading: const Icon(Icons.healing), title: const Text('Repair Sync'), @@ -63,6 +66,34 @@ class TroubleshootSyncPage extends ConsumerWidget { ); } + /// Encryption status: Off / On / Locked. Locked (flag on, no unlocked + /// session) is tappable and runs the shared unlock flow -- the most + /// common "sync stopped working" cause on a freshly restored device. + /// Uses the same localized strings as the Cloud Sync page. + Widget _buildEncryptionStatusRow(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final enabled = ref.watch(syncPreferencesProvider).syncEncryptionEnabled; + final session = ref.watch(encryptionKeyNotifierProvider); + final locked = enabled && session == null; + final String subtitle; + if (!enabled) { + subtitle = l10n.settings_cloudSync_encryption_statusOff; + } else if (locked) { + subtitle = l10n.settings_cloudSync_encryption_statusLockedSubtitle; + } else { + subtitle = l10n.settings_cloudSync_encryption_statusOn; + } + return ListTile( + leading: Icon( + locked ? Icons.lock_clock : Icons.lock_outline, + color: locked ? Theme.of(context).colorScheme.error : null, + ), + title: Text(l10n.settings_cloudSync_encryption_title), + subtitle: Text(subtitle), + onTap: locked ? () => runEncryptionUnlockFlow(context, ref) : null, + ); + } + Future _confirmRepair(BuildContext context, WidgetRef ref) async { final ok = await showDialog( context: context, diff --git a/lib/features/settings/presentation/providers/sync_providers.dart b/lib/features/settings/presentation/providers/sync_providers.dart index 7180729f3..31d602110 100644 --- a/lib/features/settings/presentation/providers/sync_providers.dart +++ b/lib/features/settings/presentation/providers/sync_providers.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'package:cryptography/cryptography.dart' show SecretKey; import 'package:package_info_plus/package_info_plus.dart'; import 'package:submersion/core/providers/provider.dart'; @@ -12,11 +13,15 @@ import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.da import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_app.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_store.dart'; import 'package:submersion/core/services/cloud_storage/dropbox_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/encrypting_cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/google_drive_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/icloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; import 'package:submersion/core/services/cloud_storage/s3_storage_provider.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; import 'package:submersion/core/services/sync/established_provider_store.dart'; import 'package:submersion/core/services/sync/library_epoch.dart'; import 'package:submersion/core/services/sync/library_epoch_store.dart'; @@ -66,6 +71,81 @@ final syncPreferencesProvider = Provider((ref) { return SyncPreferences(prefs); }); +/// Device-local custody of the encryption master key + keyslot mirror. +final encryptionKeyStoreProvider = Provider((ref) { + return EncryptionKeyStore(); +}); + +/// Encryption lifecycle operations (enable / unlock / rotate / self-heal). +final syncEncryptionServiceProvider = Provider((ref) { + return SyncEncryptionService( + keyStore: ref.watch(encryptionKeyStoreProvider), + preferences: ref.watch(syncPreferencesProvider), + ); +}); + +/// Unlocked-session state: null = encryption disabled, or enabled but locked +/// (no key on this device yet). Non-null makes the provider wrap encrypting. +class EncryptionSessionState { + final UnlockedKey key; + final SecretKey dataKey; + + const EncryptionSessionState({required this.key, required this.dataKey}); +} + +class EncryptionKeyNotifier extends StateNotifier { + EncryptionKeyNotifier(this._keyStore, this._preferences) : super(null); + + final EncryptionKeyStore _keyStore; + final SyncPreferences _preferences; + Future? _loading; + + /// Memoized load from the key store; sync triggers await this before + /// resolving the sync service so the provider wrap is already in place. + Future ensureLoaded() { + return _loading ??= _load(); + } + + Future _load() async { + if (state != null) return state; + // The stored key outlives a disable (old encrypted backups stay + // restorable via EncryptionKeyStore directly); a SESSION only exists + // while the feature flag is on, so the provider wrap follows the flag. + if (!_preferences.syncEncryptionEnabled) return null; + final key = await _keyStore.loadKey(); + if (key == null) return null; + final dataKey = await Keyslots.deriveDataKey(key.mlk); + if (!mounted) return null; + state = EncryptionSessionState(key: key, dataKey: dataKey); + return state; + } + + Future setUnlocked(UnlockedKey key) async { + final dataKey = await Keyslots.deriveDataKey(key.mlk); + if (!mounted) return; + state = EncryptionSessionState(key: key, dataKey: dataKey); + _loading = Future.value(state); + } + + Future clear() async { + state = null; + // Reset the memoization to null (not a completed null future) so a later + // ensureLoaded() genuinely re-reads secure storage -- e.g. a disable + // followed by a re-enable in the same container must pick up the new key. + _loading = null; + } +} + +final encryptionKeyNotifierProvider = + StateNotifierProvider(( + ref, + ) { + return EncryptionKeyNotifier( + ref.watch(encryptionKeyStoreProvider), + ref.watch(syncPreferencesProvider), + ); + }); + /// Library epoch persistence (mirror + pending replace intent). final libraryEpochStoreProvider = Provider((ref) { return LibraryEpochStore(ref.watch(sharedPreferencesProvider)); @@ -189,7 +269,18 @@ final cloudStorageProviderProvider = Provider((ref) { final providerType = ref.watch(selectedCloudProviderTypeProvider); if (providerType == null) return null; - return cloudProviderInstanceFor(providerType); + final raw = cloudProviderInstanceFor(providerType); + // End-to-end encryption: with an unlocked session, every byte through this + // provider is sealed/opened at the byte boundary (spec 4.1). No session + // (disabled, or enabled-but-locked) resolves to the raw provider; a locked + // library is detected downstream and halts with awaitingPassphrase. + final session = ref.watch(encryptionKeyNotifierProvider); + if (session == null) return raw; + return EncryptingCloudStorageProvider( + raw, + dataKey: session.dataKey, + libraryKeyId: session.key.libraryKeyId, + ); }); /// Sync service provider @@ -200,6 +291,7 @@ final syncServiceProvider = Provider((ref) { cloudProvider: ref.watch(cloudStorageProviderProvider), syncInitializer: ref.watch(syncInitializerProvider), epochStore: ref.watch(libraryEpochStoreProvider), + encryptionService: ref.watch(syncEncryptionServiceProvider), ); }); @@ -225,6 +317,10 @@ class SyncState { /// this device has not accepted; sync is paused until the user adopts. final bool replaceAwaitingAdoption; + /// True when the cloud library is end-to-end encrypted and this device has + /// no matching key; sync is paused until the user enters the passphrase. + final bool needsPassphrase; + /// The replacement marker behind [replaceAwaitingAdoption] (who/when). final LibraryEpochMarker? replaceMarker; @@ -255,6 +351,7 @@ class SyncState { this.firstSyncAwaitingConfirmation = false, this.postRestoreSyncing = false, this.replaceAwaitingAdoption = false, + this.needsPassphrase = false, this.replaceMarker, this.movedMarker, this.cleanupOldBackendProviderId, @@ -271,6 +368,7 @@ class SyncState { bool? firstSyncAwaitingConfirmation, bool? postRestoreSyncing, bool? replaceAwaitingAdoption, + bool? needsPassphrase, Object? replaceMarker = _markerSentinel, Object? movedMarker = _movedSentinel, Object? cleanupOldBackendProviderId = _cleanupSentinel, @@ -290,6 +388,7 @@ class SyncState { postRestoreSyncing: postRestoreSyncing ?? this.postRestoreSyncing, replaceAwaitingAdoption: replaceAwaitingAdoption ?? this.replaceAwaitingAdoption, + needsPassphrase: needsPassphrase ?? this.needsPassphrase, replaceMarker: identical(replaceMarker, _markerSentinel) ? this.replaceMarker : replaceMarker as LibraryEpochMarker?, @@ -721,6 +820,12 @@ class SyncNotifier extends StateNotifier { } _syncInFlight = true; try { + // Load any stored encryption key BEFORE resolving the sync service: + // the provider wrap watches the session, so a launch-triggered sync + // must not race the async key load and run unencrypted-eyed. + await _ref.read(encryptionKeyNotifierProvider.notifier).ensureLoaded(); + if (!mounted) return; + if (auto) { final info = await firstSyncMergeInfo(); if (info != null) { @@ -742,6 +847,7 @@ class SyncNotifier extends StateNotifier { progress: 0.0, firstSyncAwaitingConfirmation: false, replaceAwaitingAdoption: false, + needsPassphrase: false, replaceMarker: null, ); @@ -788,6 +894,19 @@ class SyncNotifier extends StateNotifier { } } + if (result.status == SyncResultStatus.awaitingPassphrase) { + state = state.copyWith( + status: SyncStatus.idle, + needsPassphrase: true, + message: + result.message ?? + 'Sync paused: this library is encrypted. ' + 'Enter the passphrase to continue.', + progress: null, + ); + return; + } + if (result.isSuccess) { final defaultMessage = result.conflictsFound > 0 ? 'Sync completed with conflicts' diff --git a/lib/features/settings/presentation/widgets/enable_encryption_dialog.dart b/lib/features/settings/presentation/widgets/enable_encryption_dialog.dart new file mode 100644 index 000000000..532bbafa5 --- /dev/null +++ b/lib/features/settings/presentation/widgets/enable_encryption_dialog.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The enable-encryption flow: passphrase form (with warnings and the +/// delete-plaintext-backups choice), then the generated recovery code with a +/// confirm-saved gate. [onEnable] runs the actual enable and returns the +/// recovery code; [onFinished] runs after the user confirms they saved it +/// (its argument is the delete-plaintext-backups checkbox). +class EnableEncryptionDialog extends StatefulWidget { + final Future Function(String passphrase) onEnable; + final Future Function(bool deletePlaintextBackups) onFinished; + + const EnableEncryptionDialog({ + super.key, + required this.onEnable, + required this.onFinished, + }); + + @override + State createState() => _EnableEncryptionDialogState(); +} + +enum _Phase { form, busy, recovery } + +class _EnableEncryptionDialogState extends State { + final _passphrase = TextEditingController(); + final _confirm = TextEditingController(); + var _phase = _Phase.form; + var _deleteBackups = true; + var _recoverySaved = false; + String? _passphraseError; + String? _confirmError; + String? _recoveryCode; + String? _enableError; + + @override + void dispose() { + _passphrase.dispose(); + _confirm.dispose(); + super.dispose(); + } + + Future _submitForm() async { + final l10n = context.l10n; + setState(() { + _passphraseError = _passphrase.text.length < 8 + ? l10n.settings_cloudSync_encryption_passphraseTooShort + : null; + _confirmError = _passphrase.text != _confirm.text + ? l10n.settings_cloudSync_encryption_passphraseMismatch + : null; + _enableError = null; + }); + if (_passphraseError != null || _confirmError != null) return; + setState(() => _phase = _Phase.busy); + try { + final code = await widget.onEnable(_passphrase.text); + if (!mounted) return; + setState(() { + _recoveryCode = code; + _phase = _Phase.recovery; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _phase = _Phase.form; + _enableError = e.toString(); + }); + } + } + + Future _finish() async { + setState(() => _phase = _Phase.busy); + await widget.onFinished(_deleteBackups); + if (mounted) Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + switch (_phase) { + case _Phase.form: + return AlertDialog( + title: Text(l10n.settings_cloudSync_encryption_enable), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _passphrase, + obscureText: true, + autofocus: true, + decoration: InputDecoration( + labelText: l10n.settings_cloudSync_encryption_passphrase, + errorText: _passphraseError, + ), + ), + TextField( + controller: _confirm, + obscureText: true, + decoration: InputDecoration( + labelText: + l10n.settings_cloudSync_encryption_passphraseConfirm, + errorText: _confirmError, + ), + ), + const SizedBox(height: 16), + _WarningText( + l10n.settings_cloudSync_encryption_warnUpdateDevices, + ), + const SizedBox(height: 8), + _WarningText(l10n.settings_cloudSync_encryption_warnLoss), + CheckboxListTile( + value: _deleteBackups, + onChanged: (v) => setState(() => _deleteBackups = v ?? true), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + l10n.settings_cloudSync_encryption_deletePlaintextBackups, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + if (_enableError != null) + Text( + _enableError!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.settings_cloudSync_encryption_cancel), + ), + FilledButton( + onPressed: _submitForm, + child: Text(l10n.settings_cloudSync_encryption_continue), + ), + ], + ); + case _Phase.busy: + return const AlertDialog( + content: SizedBox( + height: 64, + child: Center(child: CircularProgressIndicator()), + ), + ); + case _Phase.recovery: + return AlertDialog( + title: Text(l10n.settings_cloudSync_encryption_recoveryTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.settings_cloudSync_encryption_recoveryExplain), + const SizedBox(height: 12), + RecoveryCodeDisplay(code: _recoveryCode!), + CheckboxListTile( + value: _recoverySaved, + onChanged: (v) => setState(() => _recoverySaved = v ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + l10n.settings_cloudSync_encryption_recoverySavedConfirm, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + actions: [ + FilledButton( + onPressed: _recoverySaved ? _finish : null, + child: Text(l10n.settings_cloudSync_encryption_done), + ), + ], + ); + } + } +} + +class _WarningText extends StatelessWidget { + final String text; + + const _WarningText(this.text); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 18, + color: theme.colorScheme.tertiary, + ), + const SizedBox(width: 8), + Expanded(child: Text(text, style: theme.textTheme.bodySmall)), + ], + ); + } +} + +/// Selectable monospace box for a recovery code. Shared by the enable flow +/// and the regenerate flow. +class RecoveryCodeDisplay extends StatelessWidget { + final String code; + + const RecoveryCodeDisplay({super.key, required this.code}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + code, + style: theme.textTheme.bodyLarge?.copyWith(fontFamily: 'monospace'), + ), + ); + } +} diff --git a/lib/features/settings/presentation/widgets/encryption_passphrase_dialog.dart b/lib/features/settings/presentation/widgets/encryption_passphrase_dialog.dart new file mode 100644 index 000000000..744280e80 --- /dev/null +++ b/lib/features/settings/presentation/widgets/encryption_passphrase_dialog.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Prompt for a passphrase (or recovery code) and run [onSubmit] with it. +/// A [WrongPassphraseException] keeps the dialog open with an inline error; +/// any success pops with the entered secret. Returns null when cancelled. +Future showEncryptionPassphraseDialog( + BuildContext context, { + required String title, + String? hint, + required Future Function(String secret) onSubmit, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + _PassphraseDialog(title: title, hint: hint, onSubmit: onSubmit), + ); +} + +class _PassphraseDialog extends StatefulWidget { + final String title; + final String? hint; + final Future Function(String secret) onSubmit; + + const _PassphraseDialog({ + required this.title, + this.hint, + required this.onSubmit, + }); + + @override + State<_PassphraseDialog> createState() => _PassphraseDialogState(); +} + +class _PassphraseDialogState extends State<_PassphraseDialog> { + final _controller = TextEditingController(); + String? _error; + bool _busy = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submit() async { + final secret = _controller.text; + if (secret.isEmpty) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.onSubmit(secret); + if (mounted) Navigator.of(context).pop(secret); + } on WrongPassphraseException { + if (!mounted) return; + setState(() { + _busy = false; + _error = context.l10n.settings_cloudSync_encryption_wrongPassphrase; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + obscureText: true, + autofocus: true, + enabled: !_busy, + decoration: InputDecoration( + labelText: + widget.hint ?? l10n.settings_cloudSync_encryption_passphrase, + errorText: _error, + ), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: Text(l10n.settings_cloudSync_encryption_cancel), + ), + FilledButton( + onPressed: _busy ? null : _submit, + child: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.settings_cloudSync_encryption_unlock), + ), + ], + ); + } +} + +/// Change-passphrase dialog: current + new + confirm. [onSubmit] receives +/// (current, next); a [WrongPassphraseException] shows inline on the current +/// field. Returns true when the change succeeded. +Future showChangePassphraseDialog( + BuildContext context, { + required Future Function(String current, String next) onSubmit, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _ChangePassphraseDialog(onSubmit: onSubmit), + ); +} + +class _ChangePassphraseDialog extends StatefulWidget { + final Future Function(String current, String next) onSubmit; + + const _ChangePassphraseDialog({required this.onSubmit}); + + @override + State<_ChangePassphraseDialog> createState() => + _ChangePassphraseDialogState(); +} + +class _ChangePassphraseDialogState extends State<_ChangePassphraseDialog> { + final _current = TextEditingController(); + final _next = TextEditingController(); + final _confirm = TextEditingController(); + String? _currentError; + String? _nextError; + String? _confirmError; + bool _busy = false; + + @override + void dispose() { + _current.dispose(); + _next.dispose(); + _confirm.dispose(); + super.dispose(); + } + + Future _submit() async { + final l10n = context.l10n; + setState(() { + _currentError = null; + _nextError = _next.text.length < 8 + ? l10n.settings_cloudSync_encryption_passphraseTooShort + : null; + _confirmError = _next.text != _confirm.text + ? l10n.settings_cloudSync_encryption_passphraseMismatch + : null; + }); + if (_nextError != null || _confirmError != null) return; + setState(() => _busy = true); + try { + await widget.onSubmit(_current.text, _next.text); + if (mounted) Navigator.of(context).pop(true); + } on WrongPassphraseException { + if (!mounted) return; + setState(() { + _busy = false; + _currentError = + context.l10n.settings_cloudSync_encryption_wrongPassphrase; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _currentError = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(l10n.settings_cloudSync_encryption_changePassphrase), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _current, + obscureText: true, + autofocus: true, + enabled: !_busy, + decoration: InputDecoration( + labelText: l10n.settings_cloudSync_encryption_currentPassphrase, + errorText: _currentError, + ), + ), + TextField( + controller: _next, + obscureText: true, + enabled: !_busy, + decoration: InputDecoration( + labelText: l10n.settings_cloudSync_encryption_newPassphrase, + errorText: _nextError, + ), + ), + TextField( + controller: _confirm, + obscureText: true, + enabled: !_busy, + decoration: InputDecoration( + labelText: l10n.settings_cloudSync_encryption_passphraseConfirm, + errorText: _confirmError, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(false), + child: Text(l10n.settings_cloudSync_encryption_cancel), + ), + FilledButton( + onPressed: _busy ? null : _submit, + child: Text(l10n.settings_cloudSync_encryption_changePassphrase), + ), + ], + ); + } +} diff --git a/lib/features/settings/presentation/widgets/encryption_settings_section.dart b/lib/features/settings/presentation/widgets/encryption_settings_section.dart new file mode 100644 index 000000000..049098081 --- /dev/null +++ b/lib/features/settings/presentation/widgets/encryption_settings_section.dart @@ -0,0 +1,349 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/enable_encryption_dialog.dart'; +import 'package:submersion/features/settings/presentation/widgets/encryption_passphrase_dialog.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Shared unlock flow: prompt for the passphrase/recovery code, unwrap the +/// key from the cloud keyslot file, activate the session, and re-run sync. +/// Used by the section's locked tile, the sync-page banner, and the +/// Troubleshoot screen. Returns true when unlocked. +Future runEncryptionUnlockFlow( + BuildContext context, + WidgetRef ref, +) async { + // Resolve the provider up front and capture it for the closure. With no + // provider there is nothing to unlock against, so bail before showing the + // dialog rather than letting onSubmit succeed silently (which would close + // the dialog and trigger a pointless sync without any key). + final provider = ref.read(cloudStorageProviderProvider); + if (provider == null) return false; + final l10n = context.l10n; + final unlocked = await showEncryptionPassphraseDialog( + context, + title: l10n.settings_cloudSync_encryption_unlockTitle, + hint: l10n.settings_cloudSync_encryption_unlockHint, + onSubmit: (secret) async { + final key = await ref + .read(syncEncryptionServiceProvider) + .unlock(rawProvider: provider, secret: secret); + await ref.read(encryptionKeyNotifierProvider.notifier).setUnlocked(key); + }, + ); + if (unlocked != null) { + unawaited(ref.read(syncStateProvider.notifier).performSync()); + return true; + } + return false; +} + +/// The "End-to-end encryption" section of the Cloud Sync page. Renders one +/// of three states -- off, on+unlocked, on+locked -- and orchestrates the +/// lifecycle flows against the encryption service and sync notifier. +class EncryptionSettingsSection extends ConsumerStatefulWidget { + const EncryptionSettingsSection({super.key}); + + @override + ConsumerState createState() => + _EncryptionSettingsSectionState(); +} + +class _EncryptionSettingsSectionState + extends ConsumerState { + @override + void initState() { + super.initState(); + // Surface a stored key as an unlocked session on first paint (the sync + // notifier does the same before every sync; this covers pure display). + Future.microtask( + () => ref.read(encryptionKeyNotifierProvider.notifier).ensureLoaded(), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final session = ref.watch(encryptionKeyNotifierProvider); + final enabled = ref.watch(syncPreferencesProvider).syncEncryptionEnabled; + final hasProvider = ref.watch(cloudStorageProviderProvider) != null; + + final children = [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + l10n.settings_cloudSync_encryption_title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ]; + + if (!enabled) { + children.add( + ListTile( + leading: const Icon(Icons.lock_outline), + title: Text(l10n.settings_cloudSync_encryption_enable), + subtitle: Text( + hasProvider + ? l10n.settings_cloudSync_encryption_subtitleOff + : l10n.settings_cloudSync_encryption_subtitleNeedsProvider, + ), + enabled: hasProvider, + onTap: hasProvider ? () => _enable(context) : null, + ), + ); + } else if (session == null) { + children.add( + ListTile( + leading: const Icon(Icons.lock_clock), + title: Text(l10n.settings_cloudSync_encryption_statusLocked), + subtitle: Text( + l10n.settings_cloudSync_encryption_statusLockedSubtitle, + ), + trailing: FilledButton.tonal( + onPressed: () => _unlock(context), + child: Text(l10n.settings_cloudSync_encryption_enterPassphrase), + ), + ), + ); + } else { + children.addAll([ + ListTile( + leading: Icon( + Icons.lock, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(l10n.settings_cloudSync_encryption_statusOn), + subtitle: Text(l10n.settings_cloudSync_encryption_statusOnSubtitle), + ), + ListTile( + leading: const Icon(Icons.password), + title: Text(l10n.settings_cloudSync_encryption_changePassphrase), + onTap: () => _changePassphrase(context), + ), + ListTile( + leading: const Icon(Icons.key), + title: Text(l10n.settings_cloudSync_encryption_regenerateRecovery), + subtitle: Text( + l10n.settings_cloudSync_encryption_regenerateRecoveryWarn, + ), + onTap: () => _regenerateRecovery(context), + ), + ListTile( + leading: const Icon(Icons.lock_open), + title: Text(l10n.settings_cloudSync_encryption_disable), + onTap: () => _disable(context), + ), + ]); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + } + + Future<({String deviceId, String? deviceName, String? appVersion})> + _markerIdentity() async { + String deviceId; + try { + deviceId = await ref.read(syncRepositoryProvider).getDeviceId(); + } catch (_) { + deviceId = 'unknown'; + } + String? deviceName; + try { + deviceName = Platform.localHostname; + } catch (_) { + deviceName = null; + } + String? appVersion; + try { + appVersion = (await PackageInfo.fromPlatform()).version; + } catch (_) { + appVersion = null; + } + return (deviceId: deviceId, deviceName: deviceName, appVersion: appVersion); + } + + Future _enable(BuildContext context) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => EnableEncryptionDialog( + onEnable: (passphrase) async { + final provider = ref.read(cloudStorageProviderProvider)!; + final identity = await _markerIdentity(); + final result = await ref + .read(syncEncryptionServiceProvider) + .enable( + rawProvider: provider, + passphrase: passphrase, + epochStore: ref.read(libraryEpochStoreProvider), + deviceId: identity.deviceId, + deviceName: identity.deviceName, + appVersion: identity.appVersion, + ); + final key = await ref.read(encryptionKeyStoreProvider).loadKey(); + if (key != null) { + await ref + .read(encryptionKeyNotifierProvider.notifier) + .setUnlocked(key); + } + return result.recoveryCode; + }, + onFinished: (deletePlaintextBackups) async { + if (deletePlaintextBackups) { + try { + await ref + .read(backupServiceProvider) + .deletePlaintextCloudBackups(); + } catch (_) { + // Non-fatal: retention pruning ages plaintext backups out. + } + } + // Consumes the pending replace: publishes the encrypted library. + unawaited(ref.read(syncStateProvider.notifier).performSync()); + }, + ), + ); + } + + Future _unlock(BuildContext context) => + runEncryptionUnlockFlow(context, ref); + + Future _changePassphrase(BuildContext context) async { + await showChangePassphraseDialog( + context, + onSubmit: (current, next) => ref + .read(syncEncryptionServiceProvider) + .changePassphrase( + rawProvider: ref.read(cloudStorageProviderProvider)!, + currentSecret: current, + newPassphrase: next, + ), + ); + } + + Future _regenerateRecovery(BuildContext context) async { + final l10n = context.l10n; + String? newCode; + final confirmed = await showEncryptionPassphraseDialog( + context, + title: l10n.settings_cloudSync_encryption_regenerateRecovery, + onSubmit: (secret) async { + newCode = await ref + .read(syncEncryptionServiceProvider) + .regenerateRecoveryCode( + rawProvider: ref.read(cloudStorageProviderProvider)!, + passphrase: secret, + ); + }, + ); + if (confirmed == null || newCode == null || !context.mounted) return; + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => _RecoveryCodeResultDialog(code: newCode!), + ); + } + + Future _disable(BuildContext context) async { + final l10n = context.l10n; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.settings_cloudSync_encryption_disable), + content: Text(l10n.settings_cloudSync_encryption_disableWarn), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.settings_cloudSync_encryption_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.settings_cloudSync_encryption_disable), + ), + ], + ), + ); + if (confirmed != true) return; + + final identity = await _markerIdentity(); + final service = ref.read(syncEncryptionServiceProvider); + await service.disable( + epochStore: ref.read(libraryEpochStoreProvider), + deviceId: identity.deviceId, + deviceName: identity.deviceName, + appVersion: identity.appVersion, + ); + // Drop the session so the provider wrap reverts to raw (the STORED key + // survives for old encrypted backups), republish plaintext, then remove + // the now-orphaned keyslot file. + await ref.read(encryptionKeyNotifierProvider.notifier).clear(); + await ref.read(syncStateProvider.notifier).performSync(); + final provider = ref.read(cloudStorageProviderProvider); + if (provider != null) { + try { + await service.deleteCloudKeyslots(provider); + } catch (_) { + // Non-fatal: the next enable overwrites it. + } + } + } +} + +class _RecoveryCodeResultDialog extends StatefulWidget { + final String code; + + const _RecoveryCodeResultDialog({required this.code}); + + @override + State<_RecoveryCodeResultDialog> createState() => + _RecoveryCodeResultDialogState(); +} + +class _RecoveryCodeResultDialogState extends State<_RecoveryCodeResultDialog> { + bool _saved = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(l10n.settings_cloudSync_encryption_recoveryTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.settings_cloudSync_encryption_recoveryExplain), + const SizedBox(height: 12), + RecoveryCodeDisplay(code: widget.code), + CheckboxListTile( + value: _saved, + onChanged: (v) => setState(() => _saved = v ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + l10n.settings_cloudSync_encryption_recoverySavedConfirm, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + actions: [ + FilledButton( + onPressed: _saved ? () => Navigator.of(context).pop() : null, + child: Text(l10n.settings_cloudSync_encryption_done), + ), + ], + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index c6ea2e4d5..99e8a53b4 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "مزامنة iCloud غير متوفرة في هذا الإصدار من Submersion. استخدم مزامنة S3 أو نسخة App Store.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "غير متوفر في هذا الإصدار — استخدم S3 أو نسخة App Store", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "التشفير من طرف إلى طرف", + "settings_cloudSync_encryption_subtitleOff": "تشفير جميع بيانات المزامنة والنسخ الاحتياطية السحابية قبل الرفع", + "settings_cloudSync_encryption_subtitleNeedsProvider": "اختر مزود التخزين السحابي أولاً", + "settings_cloudSync_encryption_statusOff": "التشفير معطّل", + "settings_cloudSync_encryption_statusOn": "التشفير مفعّل", + "settings_cloudSync_encryption_statusOnSubtitle": "يتم تشفير بيانات المزامنة والنسخ الاحتياطية السحابية قبل الرفع", + "settings_cloudSync_encryption_statusLocked": "مشفّر — مطلوب عبارة المرور", + "settings_cloudSync_encryption_statusLockedSubtitle": "أدخل عبارة المرور للمزامنة على هذا الجهاز", + "settings_cloudSync_encryption_enable": "تفعيل التشفير", + "settings_cloudSync_encryption_enterPassphrase": "إدخال عبارة المرور", + "settings_cloudSync_encryption_passphrase": "عبارة المرور", + "settings_cloudSync_encryption_passphraseConfirm": "تأكيد عبارة المرور", + "settings_cloudSync_encryption_passphraseMismatch": "عبارتا المرور غير متطابقتين", + "settings_cloudSync_encryption_passphraseTooShort": "استخدم 8 أحرف على الأقل", + "settings_cloudSync_encryption_wrongPassphrase": "عبارة مرور أو رمز استرداد غير صحيح", + "settings_cloudSync_encryption_warnUpdateDevices": "يجب تحديث جميع الأجهزة الأخرى إلى أحدث إصدار من التطبيق وستعيد تنزيل المكتبة.", + "settings_cloudSync_encryption_warnLoss": "إذا فقدت عبارة المرور ورمز الاسترداد معًا، فلا يمكن استرداد البيانات في السحابة. بيانات أجهزتك ليست في خطر أبدًا.", + "settings_cloudSync_encryption_deletePlaintextBackups": "حذف النسخ الاحتياطية السحابية غير المشفرة الموجودة", + "settings_cloudSync_encryption_recoveryTitle": "رمز الاسترداد", + "settings_cloudSync_encryption_recoveryExplain": "دوّن هذا الرمز واحفظه في مكان آمن. إنه الطريقة الوحيدة للعودة إذا نسيت عبارة المرور.", + "settings_cloudSync_encryption_recoverySavedConfirm": "لقد حفظت رمز الاسترداد الخاص بي", + "settings_cloudSync_encryption_changePassphrase": "تغيير عبارة المرور", + "settings_cloudSync_encryption_currentPassphrase": "عبارة المرور الحالية", + "settings_cloudSync_encryption_newPassphrase": "عبارة المرور الجديدة", + "settings_cloudSync_encryption_regenerateRecovery": "إنشاء رمز استرداد جديد", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "يتوقف رمز الاسترداد القديم عن العمل فورًا.", + "settings_cloudSync_encryption_disable": "إيقاف التشفير", + "settings_cloudSync_encryption_disableWarn": "سيُعاد رفع المكتبة دون تشفير وستعيد الأجهزة الأخرى تنزيلها. تظل النسخ الاحتياطية المشفرة الموجودة قابلة للاستعادة بعبارة المرور.", + "settings_cloudSync_encryption_unlockTitle": "أدخل عبارة مرور التشفير", + "settings_cloudSync_encryption_unlockHint": "عبارة المرور أو رمز الاسترداد", + "settings_cloudSync_encryption_unlock": "فتح", + "settings_cloudSync_encryption_continue": "متابعة", + "settings_cloudSync_encryption_done": "تم", + "settings_cloudSync_encryption_cancel": "إلغاء", "@@locale": "ar", "settings_cloudSync_replace_globalBanner": "المزامنة متوقفة مؤقتًا — تم استبدال المكتبة من نسخة احتياطية على \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "تتم مزامنة مكتبتك المستعادة مع السحابة…", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8cddea63d..8bf43fd90 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "iCloud-Synchronisierung ist in diesem Build von Submersion nicht verfügbar. Verwende die S3-Synchronisierung oder die App-Store-Version.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "In diesem Build nicht verfügbar – verwende S3 oder die App-Store-Version", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "Ende-zu-Ende-Verschlüsselung", + "settings_cloudSync_encryption_subtitleOff": "Alle Sync-Daten und Cloud-Backups vor dem Hochladen verschlüsseln", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Zuerst einen Cloud-Anbieter auswählen", + "settings_cloudSync_encryption_statusOff": "Verschlüsselung ist aus", + "settings_cloudSync_encryption_statusOn": "Verschlüsselung ist aktiv", + "settings_cloudSync_encryption_statusOnSubtitle": "Sync-Daten und Cloud-Backups werden vor dem Hochladen verschlüsselt", + "settings_cloudSync_encryption_statusLocked": "Verschlüsselt — Passphrase erforderlich", + "settings_cloudSync_encryption_statusLockedSubtitle": "Passphrase eingeben, um auf diesem Gerät zu synchronisieren", + "settings_cloudSync_encryption_enable": "Verschlüsselung aktivieren", + "settings_cloudSync_encryption_enterPassphrase": "Passphrase eingeben", + "settings_cloudSync_encryption_passphrase": "Passphrase", + "settings_cloudSync_encryption_passphraseConfirm": "Passphrase bestätigen", + "settings_cloudSync_encryption_passphraseMismatch": "Passphrasen stimmen nicht überein", + "settings_cloudSync_encryption_passphraseTooShort": "Mindestens 8 Zeichen verwenden", + "settings_cloudSync_encryption_wrongPassphrase": "Falsche Passphrase oder falscher Wiederherstellungscode", + "settings_cloudSync_encryption_warnUpdateDevices": "Alle anderen Geräte müssen auf die neueste App-Version aktualisiert werden und laden die Bibliothek erneut herunter.", + "settings_cloudSync_encryption_warnLoss": "Gehen Passphrase und Wiederherstellungscode verloren, können die Daten in der Cloud nicht wiederhergestellt werden. Die Daten auf Ihren Geräten sind nie in Gefahr.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Vorhandene unverschlüsselte Cloud-Backups löschen", + "settings_cloudSync_encryption_recoveryTitle": "Wiederherstellungscode", + "settings_cloudSync_encryption_recoveryExplain": "Notieren Sie diesen Code und bewahren Sie ihn sicher auf. Er ist der einzige Weg zurück, falls Sie die Passphrase vergessen.", + "settings_cloudSync_encryption_recoverySavedConfirm": "Ich habe meinen Wiederherstellungscode gespeichert", + "settings_cloudSync_encryption_changePassphrase": "Passphrase ändern", + "settings_cloudSync_encryption_currentPassphrase": "Aktuelle Passphrase", + "settings_cloudSync_encryption_newPassphrase": "Neue Passphrase", + "settings_cloudSync_encryption_regenerateRecovery": "Neuen Wiederherstellungscode erzeugen", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "Der alte Wiederherstellungscode wird sofort ungültig.", + "settings_cloudSync_encryption_disable": "Verschlüsselung ausschalten", + "settings_cloudSync_encryption_disableWarn": "Die Bibliothek wird unverschlüsselt neu hochgeladen und andere Geräte laden sie erneut herunter. Vorhandene verschlüsselte Backups bleiben mit der Passphrase wiederherstellbar.", + "settings_cloudSync_encryption_unlockTitle": "Verschlüsselungs-Passphrase eingeben", + "settings_cloudSync_encryption_unlockHint": "Passphrase oder Wiederherstellungscode", + "settings_cloudSync_encryption_unlock": "Entsperren", + "settings_cloudSync_encryption_continue": "Weiter", + "settings_cloudSync_encryption_done": "Fertig", + "settings_cloudSync_encryption_cancel": "Abbrechen", "@@locale": "de", "settings_cloudSync_replace_globalBanner": "Synchronisierung pausiert — die Bibliothek wurde aus einem Backup auf \"{deviceName}\" ersetzt.", "settings_cloudSync_postRestore_syncing": "Wiederhergestellte Bibliothek wird mit der Cloud synchronisiert…", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 50590c202..75a2e39ef 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -41,6 +41,40 @@ "settings_cloudSync_error_icloudUnknown": "Couldn't reach iCloud. Please try again.", "settings_cloudSync_error_icloudUnsupported": "iCloud sync isn't available in this build of Submersion. Use S3 sync, or the App Store version.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "Not available in this build — use S3 or the App Store version", + "settings_cloudSync_encryption_title": "End-to-end encryption", + "settings_cloudSync_encryption_subtitleOff": "Encrypt all sync data and cloud backups before upload", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Select a cloud provider first", + "settings_cloudSync_encryption_statusOff": "Encryption is off", + "settings_cloudSync_encryption_statusOn": "Encryption is on", + "settings_cloudSync_encryption_statusOnSubtitle": "Sync data and cloud backups are encrypted before upload", + "settings_cloudSync_encryption_statusLocked": "Encrypted — passphrase needed", + "settings_cloudSync_encryption_statusLockedSubtitle": "Enter the passphrase to sync on this device", + "settings_cloudSync_encryption_enable": "Enable encryption", + "settings_cloudSync_encryption_enterPassphrase": "Enter passphrase", + "settings_cloudSync_encryption_passphrase": "Passphrase", + "settings_cloudSync_encryption_passphraseConfirm": "Confirm passphrase", + "settings_cloudSync_encryption_passphraseMismatch": "Passphrases do not match", + "settings_cloudSync_encryption_passphraseTooShort": "Use at least 8 characters", + "settings_cloudSync_encryption_wrongPassphrase": "Incorrect passphrase or recovery code", + "settings_cloudSync_encryption_warnUpdateDevices": "All other devices must be updated to the latest app version and will re-download the library.", + "settings_cloudSync_encryption_warnLoss": "If you lose both the passphrase and the recovery code, data in the cloud cannot be recovered. Data on your devices is never at risk.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Delete existing unencrypted cloud backups", + "settings_cloudSync_encryption_recoveryTitle": "Recovery code", + "settings_cloudSync_encryption_recoveryExplain": "Write this code down and keep it somewhere safe. It is the only way back in if you forget your passphrase.", + "settings_cloudSync_encryption_recoverySavedConfirm": "I have saved my recovery code", + "settings_cloudSync_encryption_changePassphrase": "Change passphrase", + "settings_cloudSync_encryption_currentPassphrase": "Current passphrase", + "settings_cloudSync_encryption_newPassphrase": "New passphrase", + "settings_cloudSync_encryption_regenerateRecovery": "Generate new recovery code", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "The old recovery code stops working immediately.", + "settings_cloudSync_encryption_disable": "Turn off encryption", + "settings_cloudSync_encryption_disableWarn": "The library will be re-uploaded unencrypted, and other devices will re-download it. Existing encrypted backups stay restorable with the passphrase.", + "settings_cloudSync_encryption_unlockTitle": "Enter your encryption passphrase", + "settings_cloudSync_encryption_unlockHint": "Passphrase or recovery code", + "settings_cloudSync_encryption_unlock": "Unlock", + "settings_cloudSync_encryption_continue": "Continue", + "settings_cloudSync_encryption_done": "Done", + "settings_cloudSync_encryption_cancel": "Cancel", "@@locale": "en", "settings_cloudSync_replace_globalBanner": "Sync is paused — the library was replaced from a backup on \"{deviceName}\".", "@settings_cloudSync_replace_globalBanner": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 983c2a2cd..b3213ec11 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "La sincronización con iCloud no está disponible en esta versión de Submersion. Usa la sincronización S3 o la versión de la App Store.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "No disponible en esta versión: usa S3 o la versión de la App Store", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "Cifrado de extremo a extremo", + "settings_cloudSync_encryption_subtitleOff": "Cifrar todos los datos de sincronización y las copias en la nube antes de subirlos", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Selecciona primero un proveedor de nube", + "settings_cloudSync_encryption_statusOff": "El cifrado está desactivado", + "settings_cloudSync_encryption_statusOn": "El cifrado está activado", + "settings_cloudSync_encryption_statusOnSubtitle": "Los datos de sincronización y las copias en la nube se cifran antes de subirse", + "settings_cloudSync_encryption_statusLocked": "Cifrado — se necesita la frase de acceso", + "settings_cloudSync_encryption_statusLockedSubtitle": "Introduce la frase de acceso para sincronizar en este dispositivo", + "settings_cloudSync_encryption_enable": "Activar el cifrado", + "settings_cloudSync_encryption_enterPassphrase": "Introducir frase de acceso", + "settings_cloudSync_encryption_passphrase": "Frase de acceso", + "settings_cloudSync_encryption_passphraseConfirm": "Confirmar frase de acceso", + "settings_cloudSync_encryption_passphraseMismatch": "Las frases de acceso no coinciden", + "settings_cloudSync_encryption_passphraseTooShort": "Usa al menos 8 caracteres", + "settings_cloudSync_encryption_wrongPassphrase": "Frase de acceso o código de recuperación incorrectos", + "settings_cloudSync_encryption_warnUpdateDevices": "Todos los demás dispositivos deben actualizarse a la última versión de la app y volverán a descargar la biblioteca.", + "settings_cloudSync_encryption_warnLoss": "Si pierdes la frase de acceso y el código de recuperación, los datos en la nube no se podrán recuperar. Los datos en tus dispositivos nunca corren riesgo.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Eliminar las copias en la nube sin cifrar existentes", + "settings_cloudSync_encryption_recoveryTitle": "Código de recuperación", + "settings_cloudSync_encryption_recoveryExplain": "Apunta este código y guárdalo en un lugar seguro. Es la única forma de volver a entrar si olvidas la frase de acceso.", + "settings_cloudSync_encryption_recoverySavedConfirm": "He guardado mi código de recuperación", + "settings_cloudSync_encryption_changePassphrase": "Cambiar frase de acceso", + "settings_cloudSync_encryption_currentPassphrase": "Frase de acceso actual", + "settings_cloudSync_encryption_newPassphrase": "Nueva frase de acceso", + "settings_cloudSync_encryption_regenerateRecovery": "Generar nuevo código de recuperación", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "El código de recuperación anterior deja de funcionar de inmediato.", + "settings_cloudSync_encryption_disable": "Desactivar el cifrado", + "settings_cloudSync_encryption_disableWarn": "La biblioteca se volverá a subir sin cifrar y los demás dispositivos la descargarán de nuevo. Las copias cifradas existentes siguen siendo restaurables con la frase de acceso.", + "settings_cloudSync_encryption_unlockTitle": "Introduce tu frase de acceso de cifrado", + "settings_cloudSync_encryption_unlockHint": "Frase de acceso o código de recuperación", + "settings_cloudSync_encryption_unlock": "Desbloquear", + "settings_cloudSync_encryption_continue": "Continuar", + "settings_cloudSync_encryption_done": "Hecho", + "settings_cloudSync_encryption_cancel": "Cancelar", "@@locale": "es", "settings_cloudSync_replace_globalBanner": "Sincronización en pausa: la biblioteca se reemplazó desde una copia de seguridad en \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "Sincronizando tu biblioteca restaurada con la nube…", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index d3e3cccbd..2b798d87c 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "La synchronisation iCloud n'est pas disponible dans cette version de Submersion. Utilisez la synchronisation S3 ou la version de l'App Store.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "Indisponible dans cette version — utilisez S3 ou la version de l'App Store", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "Chiffrement de bout en bout", + "settings_cloudSync_encryption_subtitleOff": "Chiffrer toutes les données de synchronisation et les sauvegardes cloud avant l'envoi", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Sélectionnez d'abord un fournisseur cloud", + "settings_cloudSync_encryption_statusOff": "Le chiffrement est désactivé", + "settings_cloudSync_encryption_statusOn": "Le chiffrement est activé", + "settings_cloudSync_encryption_statusOnSubtitle": "Les données de synchronisation et les sauvegardes cloud sont chiffrées avant l'envoi", + "settings_cloudSync_encryption_statusLocked": "Chiffré — phrase secrète requise", + "settings_cloudSync_encryption_statusLockedSubtitle": "Saisissez la phrase secrète pour synchroniser sur cet appareil", + "settings_cloudSync_encryption_enable": "Activer le chiffrement", + "settings_cloudSync_encryption_enterPassphrase": "Saisir la phrase secrète", + "settings_cloudSync_encryption_passphrase": "Phrase secrète", + "settings_cloudSync_encryption_passphraseConfirm": "Confirmer la phrase secrète", + "settings_cloudSync_encryption_passphraseMismatch": "Les phrases secrètes ne correspondent pas", + "settings_cloudSync_encryption_passphraseTooShort": "Utilisez au moins 8 caractères", + "settings_cloudSync_encryption_wrongPassphrase": "Phrase secrète ou code de récupération incorrect", + "settings_cloudSync_encryption_warnUpdateDevices": "Tous les autres appareils doivent être mis à jour vers la dernière version de l'application et retélechargeront la bibliothèque.", + "settings_cloudSync_encryption_warnLoss": "Si vous perdez la phrase secrète et le code de récupération, les données dans le cloud seront irrécupérables. Les données sur vos appareils ne sont jamais menacées.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Supprimer les sauvegardes cloud non chiffrées existantes", + "settings_cloudSync_encryption_recoveryTitle": "Code de récupération", + "settings_cloudSync_encryption_recoveryExplain": "Notez ce code et conservez-le en lieu sûr. C'est le seul moyen de récupérer l'accès si vous oubliez la phrase secrète.", + "settings_cloudSync_encryption_recoverySavedConfirm": "J'ai enregistré mon code de récupération", + "settings_cloudSync_encryption_changePassphrase": "Changer la phrase secrète", + "settings_cloudSync_encryption_currentPassphrase": "Phrase secrète actuelle", + "settings_cloudSync_encryption_newPassphrase": "Nouvelle phrase secrète", + "settings_cloudSync_encryption_regenerateRecovery": "Générer un nouveau code de récupération", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "L'ancien code de récupération cesse immédiatement de fonctionner.", + "settings_cloudSync_encryption_disable": "Désactiver le chiffrement", + "settings_cloudSync_encryption_disableWarn": "La bibliothèque sera renvoyée non chiffrée et les autres appareils la retéléchargeront. Les sauvegardes chiffrées existantes restent restaurables avec la phrase secrète.", + "settings_cloudSync_encryption_unlockTitle": "Saisissez votre phrase secrète de chiffrement", + "settings_cloudSync_encryption_unlockHint": "Phrase secrète ou code de récupération", + "settings_cloudSync_encryption_unlock": "Déverrouiller", + "settings_cloudSync_encryption_continue": "Continuer", + "settings_cloudSync_encryption_done": "Terminé", + "settings_cloudSync_encryption_cancel": "Annuler", "@@locale": "fr", "settings_cloudSync_replace_globalBanner": "Synchronisation en pause — la bibliothèque a été remplacée depuis une sauvegarde sur \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "Synchronisation de votre bibliothèque restaurée avec le cloud…", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 538314ca3..2284a3826 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "סנכרון iCloud אינו זמין בגרסה זו של Submersion. השתמש בסנכרון S3 או בגרסת App Store.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "לא זמין בגרסה זו — השתמש ב-S3 או בגרסת App Store", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "הצפנה מקצה לקצה", + "settings_cloudSync_encryption_subtitleOff": "הצפנת כל נתוני הסנכרון והגיבויים בענן לפני ההעלאה", + "settings_cloudSync_encryption_subtitleNeedsProvider": "בחרו קודם ספק ענן", + "settings_cloudSync_encryption_statusOff": "ההצפנה כבויה", + "settings_cloudSync_encryption_statusOn": "ההצפנה פועלת", + "settings_cloudSync_encryption_statusOnSubtitle": "נתוני סנכרון וגיבויים בענן מוצפנים לפני ההעלאה", + "settings_cloudSync_encryption_statusLocked": "מוצפן — נדרש משפט סיסמה", + "settings_cloudSync_encryption_statusLockedSubtitle": "הזינו את משפט הסיסמה כדי לסנכרן במכשיר זה", + "settings_cloudSync_encryption_enable": "הפעלת הצפנה", + "settings_cloudSync_encryption_enterPassphrase": "הזנת משפט סיסמה", + "settings_cloudSync_encryption_passphrase": "משפט סיסמה", + "settings_cloudSync_encryption_passphraseConfirm": "אישור משפט הסיסמה", + "settings_cloudSync_encryption_passphraseMismatch": "משפטי הסיסמה אינם תואמים", + "settings_cloudSync_encryption_passphraseTooShort": "השתמשו ב-8 תווים לפחות", + "settings_cloudSync_encryption_wrongPassphrase": "משפט סיסמה או קוד שחזור שגויים", + "settings_cloudSync_encryption_warnUpdateDevices": "יש לעדכן את כל שאר המכשירים לגרסת האפליקציה האחרונה והם יורידו מחדש את הספרייה.", + "settings_cloudSync_encryption_warnLoss": "אם תאבדו גם את משפט הסיסמה וגם את קוד השחזור, לא ניתן יהיה לשחזר את הנתונים בענן. הנתונים במכשירים שלכם לעולם אינם בסיכון.", + "settings_cloudSync_encryption_deletePlaintextBackups": "מחיקת גיבויי ענן לא מוצפנים קיימים", + "settings_cloudSync_encryption_recoveryTitle": "קוד שחזור", + "settings_cloudSync_encryption_recoveryExplain": "רשמו את הקוד הזה ושמרו אותו במקום בטוח. זו הדרך היחידה לחזור אם תשכחו את משפט הסיסמה.", + "settings_cloudSync_encryption_recoverySavedConfirm": "שמרתי את קוד השחזור שלי", + "settings_cloudSync_encryption_changePassphrase": "שינוי משפט סיסמה", + "settings_cloudSync_encryption_currentPassphrase": "משפט הסיסמה הנוכחי", + "settings_cloudSync_encryption_newPassphrase": "משפט סיסמה חדש", + "settings_cloudSync_encryption_regenerateRecovery": "יצירת קוד שחזור חדש", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "קוד השחזור הישן מפסיק לפעול מיד.", + "settings_cloudSync_encryption_disable": "כיבוי ההצפנה", + "settings_cloudSync_encryption_disableWarn": "הספרייה תועלה מחדש ללא הצפנה ושאר המכשירים יורידו אותה שוב. גיבויים מוצפנים קיימים יישארו ניתנים לשחזור עם משפט הסיסמה.", + "settings_cloudSync_encryption_unlockTitle": "הזינו את משפט הסיסמה של ההצפנה", + "settings_cloudSync_encryption_unlockHint": "משפט סיסמה או קוד שחזור", + "settings_cloudSync_encryption_unlock": "ביטול נעילה", + "settings_cloudSync_encryption_continue": "המשך", + "settings_cloudSync_encryption_done": "סיום", + "settings_cloudSync_encryption_cancel": "ביטול", "@@locale": "he", "settings_cloudSync_replace_globalBanner": "הסנכרון מושהה — הספרייה הוחלפה מגיבוי במכשיר \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "מסנכרן את הספרייה המשוחזרת שלך עם הענן…", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 3db1711cf..e355b5472 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "Az iCloud-szinkronizálás nem érhető el a Submersion ezen buildjében. Használd az S3-szinkronizálást vagy az App Store-verziót.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "Ebben a buildben nem érhető el – használj S3-at vagy az App Store-verziót", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "Végpontok közötti titkosítás", + "settings_cloudSync_encryption_subtitleOff": "Minden szinkronizálási adat és felhőmentés titkosítása feltöltés előtt", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Előbb válasszon felhőszolgáltatót", + "settings_cloudSync_encryption_statusOff": "A titkosítás ki van kapcsolva", + "settings_cloudSync_encryption_statusOn": "A titkosítás be van kapcsolva", + "settings_cloudSync_encryption_statusOnSubtitle": "A szinkronizálási adatok és a felhőmentések feltöltés előtt titkosítva lesznek", + "settings_cloudSync_encryption_statusLocked": "Titkosítva — jelmondat szükséges", + "settings_cloudSync_encryption_statusLockedSubtitle": "Adja meg a jelmondatot a szinkronizáláshoz ezen az eszközön", + "settings_cloudSync_encryption_enable": "Titkosítás bekapcsolása", + "settings_cloudSync_encryption_enterPassphrase": "Jelmondat megadása", + "settings_cloudSync_encryption_passphrase": "Jelmondat", + "settings_cloudSync_encryption_passphraseConfirm": "Jelmondat megerősítése", + "settings_cloudSync_encryption_passphraseMismatch": "A jelmondatok nem egyeznek", + "settings_cloudSync_encryption_passphraseTooShort": "Legalább 8 karaktert használjon", + "settings_cloudSync_encryption_wrongPassphrase": "Hibás jelmondat vagy helyreállítási kód", + "settings_cloudSync_encryption_warnUpdateDevices": "Minden más eszközt a legújabb alkalmazásverzióra kell frissíteni, és újra letöltik a könyvtárat.", + "settings_cloudSync_encryption_warnLoss": "Ha a jelmondat és a helyreállítási kód is elvész, a felhőben lévő adatok nem állíthatók helyre. Az eszközein lévő adatok soha nincsenek veszélyben.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Meglévő titkosítatlan felhőmentések törlése", + "settings_cloudSync_encryption_recoveryTitle": "Helyreállítási kód", + "settings_cloudSync_encryption_recoveryExplain": "Írja fel ezt a kódot, és őrizze biztonságos helyen. Ez az egyetlen visszaút, ha elfelejti a jelmondatot.", + "settings_cloudSync_encryption_recoverySavedConfirm": "Elmentettem a helyreállítási kódomat", + "settings_cloudSync_encryption_changePassphrase": "Jelmondat módosítása", + "settings_cloudSync_encryption_currentPassphrase": "Jelenlegi jelmondat", + "settings_cloudSync_encryption_newPassphrase": "Új jelmondat", + "settings_cloudSync_encryption_regenerateRecovery": "Új helyreállítási kód létrehozása", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "A régi helyreállítási kód azonnal érvénytelenné válik.", + "settings_cloudSync_encryption_disable": "Titkosítás kikapcsolása", + "settings_cloudSync_encryption_disableWarn": "A könyvtár titkosítatlanul lesz újra feltöltve, és a többi eszköz újra letölti. A meglévő titkosított mentések a jelmondattal továbbra is visszaállíthatók.", + "settings_cloudSync_encryption_unlockTitle": "Adja meg a titkosítási jelmondatot", + "settings_cloudSync_encryption_unlockHint": "Jelmondat vagy helyreállítási kód", + "settings_cloudSync_encryption_unlock": "Feloldás", + "settings_cloudSync_encryption_continue": "Folytatás", + "settings_cloudSync_encryption_done": "Kész", + "settings_cloudSync_encryption_cancel": "Mégse", "@@locale": "hu", "settings_cloudSync_replace_globalBanner": "A szinkronizálás szünetel — a könyvtárat egy biztonsági másolatból cserélték itt: \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "A visszaállított könyvtár szinkronizálása a felhővel…", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index a758ba652..de80e882e 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "La sincronizzazione iCloud non è disponibile in questa build di Submersion. Usa la sincronizzazione S3 o la versione dell'App Store.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "Non disponibile in questa build: usa S3 o la versione dell'App Store", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "Crittografia end-to-end", + "settings_cloudSync_encryption_subtitleOff": "Cripta tutti i dati di sincronizzazione e i backup cloud prima del caricamento", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Seleziona prima un provider cloud", + "settings_cloudSync_encryption_statusOff": "La crittografia è disattivata", + "settings_cloudSync_encryption_statusOn": "La crittografia è attiva", + "settings_cloudSync_encryption_statusOnSubtitle": "I dati di sincronizzazione e i backup cloud vengono criptati prima del caricamento", + "settings_cloudSync_encryption_statusLocked": "Criptato — serve la passphrase", + "settings_cloudSync_encryption_statusLockedSubtitle": "Inserisci la passphrase per sincronizzare su questo dispositivo", + "settings_cloudSync_encryption_enable": "Attiva la crittografia", + "settings_cloudSync_encryption_enterPassphrase": "Inserisci la passphrase", + "settings_cloudSync_encryption_passphrase": "Passphrase", + "settings_cloudSync_encryption_passphraseConfirm": "Conferma la passphrase", + "settings_cloudSync_encryption_passphraseMismatch": "Le passphrase non coincidono", + "settings_cloudSync_encryption_passphraseTooShort": "Usa almeno 8 caratteri", + "settings_cloudSync_encryption_wrongPassphrase": "Passphrase o codice di recupero errati", + "settings_cloudSync_encryption_warnUpdateDevices": "Tutti gli altri dispositivi devono essere aggiornati all'ultima versione dell'app e scaricheranno di nuovo la libreria.", + "settings_cloudSync_encryption_warnLoss": "Se perdi sia la passphrase sia il codice di recupero, i dati nel cloud non potranno essere recuperati. I dati sui tuoi dispositivi non sono mai a rischio.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Elimina i backup cloud non criptati esistenti", + "settings_cloudSync_encryption_recoveryTitle": "Codice di recupero", + "settings_cloudSync_encryption_recoveryExplain": "Annota questo codice e conservalo al sicuro. È l'unico modo per rientrare se dimentichi la passphrase.", + "settings_cloudSync_encryption_recoverySavedConfirm": "Ho salvato il mio codice di recupero", + "settings_cloudSync_encryption_changePassphrase": "Cambia passphrase", + "settings_cloudSync_encryption_currentPassphrase": "Passphrase attuale", + "settings_cloudSync_encryption_newPassphrase": "Nuova passphrase", + "settings_cloudSync_encryption_regenerateRecovery": "Genera un nuovo codice di recupero", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "Il vecchio codice di recupero smette subito di funzionare.", + "settings_cloudSync_encryption_disable": "Disattiva la crittografia", + "settings_cloudSync_encryption_disableWarn": "La libreria verrà ricaricata non criptata e gli altri dispositivi la scaricheranno di nuovo. I backup criptati esistenti restano ripristinabili con la passphrase.", + "settings_cloudSync_encryption_unlockTitle": "Inserisci la passphrase di crittografia", + "settings_cloudSync_encryption_unlockHint": "Passphrase o codice di recupero", + "settings_cloudSync_encryption_unlock": "Sblocca", + "settings_cloudSync_encryption_continue": "Continua", + "settings_cloudSync_encryption_done": "Fatto", + "settings_cloudSync_encryption_cancel": "Annulla", "@@locale": "it", "settings_cloudSync_replace_globalBanner": "Sincronizzazione in pausa: la libreria è stata sostituita da un backup su \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "Sincronizzazione della libreria ripristinata con il cloud…", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 0fda51079..809e7f062 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -284,6 +284,210 @@ abstract class AppLocalizations { /// **'Not available in this build — use S3 or the App Store version'** String get settings_cloudSync_provider_icloud_unsupportedSubtitle; + /// No description provided for @settings_cloudSync_encryption_title. + /// + /// In en, this message translates to: + /// **'End-to-end encryption'** + String get settings_cloudSync_encryption_title; + + /// No description provided for @settings_cloudSync_encryption_subtitleOff. + /// + /// In en, this message translates to: + /// **'Encrypt all sync data and cloud backups before upload'** + String get settings_cloudSync_encryption_subtitleOff; + + /// No description provided for @settings_cloudSync_encryption_subtitleNeedsProvider. + /// + /// In en, this message translates to: + /// **'Select a cloud provider first'** + String get settings_cloudSync_encryption_subtitleNeedsProvider; + + /// No description provided for @settings_cloudSync_encryption_statusOff. + /// + /// In en, this message translates to: + /// **'Encryption is off'** + String get settings_cloudSync_encryption_statusOff; + + /// No description provided for @settings_cloudSync_encryption_statusOn. + /// + /// In en, this message translates to: + /// **'Encryption is on'** + String get settings_cloudSync_encryption_statusOn; + + /// No description provided for @settings_cloudSync_encryption_statusOnSubtitle. + /// + /// In en, this message translates to: + /// **'Sync data and cloud backups are encrypted before upload'** + String get settings_cloudSync_encryption_statusOnSubtitle; + + /// No description provided for @settings_cloudSync_encryption_statusLocked. + /// + /// In en, this message translates to: + /// **'Encrypted — passphrase needed'** + String get settings_cloudSync_encryption_statusLocked; + + /// No description provided for @settings_cloudSync_encryption_statusLockedSubtitle. + /// + /// In en, this message translates to: + /// **'Enter the passphrase to sync on this device'** + String get settings_cloudSync_encryption_statusLockedSubtitle; + + /// No description provided for @settings_cloudSync_encryption_enable. + /// + /// In en, this message translates to: + /// **'Enable encryption'** + String get settings_cloudSync_encryption_enable; + + /// No description provided for @settings_cloudSync_encryption_enterPassphrase. + /// + /// In en, this message translates to: + /// **'Enter passphrase'** + String get settings_cloudSync_encryption_enterPassphrase; + + /// No description provided for @settings_cloudSync_encryption_passphrase. + /// + /// In en, this message translates to: + /// **'Passphrase'** + String get settings_cloudSync_encryption_passphrase; + + /// No description provided for @settings_cloudSync_encryption_passphraseConfirm. + /// + /// In en, this message translates to: + /// **'Confirm passphrase'** + String get settings_cloudSync_encryption_passphraseConfirm; + + /// No description provided for @settings_cloudSync_encryption_passphraseMismatch. + /// + /// In en, this message translates to: + /// **'Passphrases do not match'** + String get settings_cloudSync_encryption_passphraseMismatch; + + /// No description provided for @settings_cloudSync_encryption_passphraseTooShort. + /// + /// In en, this message translates to: + /// **'Use at least 8 characters'** + String get settings_cloudSync_encryption_passphraseTooShort; + + /// No description provided for @settings_cloudSync_encryption_wrongPassphrase. + /// + /// In en, this message translates to: + /// **'Incorrect passphrase or recovery code'** + String get settings_cloudSync_encryption_wrongPassphrase; + + /// No description provided for @settings_cloudSync_encryption_warnUpdateDevices. + /// + /// In en, this message translates to: + /// **'All other devices must be updated to the latest app version and will re-download the library.'** + String get settings_cloudSync_encryption_warnUpdateDevices; + + /// No description provided for @settings_cloudSync_encryption_warnLoss. + /// + /// In en, this message translates to: + /// **'If you lose both the passphrase and the recovery code, data in the cloud cannot be recovered. Data on your devices is never at risk.'** + String get settings_cloudSync_encryption_warnLoss; + + /// No description provided for @settings_cloudSync_encryption_deletePlaintextBackups. + /// + /// In en, this message translates to: + /// **'Delete existing unencrypted cloud backups'** + String get settings_cloudSync_encryption_deletePlaintextBackups; + + /// No description provided for @settings_cloudSync_encryption_recoveryTitle. + /// + /// In en, this message translates to: + /// **'Recovery code'** + String get settings_cloudSync_encryption_recoveryTitle; + + /// No description provided for @settings_cloudSync_encryption_recoveryExplain. + /// + /// In en, this message translates to: + /// **'Write this code down and keep it somewhere safe. It is the only way back in if you forget your passphrase.'** + String get settings_cloudSync_encryption_recoveryExplain; + + /// No description provided for @settings_cloudSync_encryption_recoverySavedConfirm. + /// + /// In en, this message translates to: + /// **'I have saved my recovery code'** + String get settings_cloudSync_encryption_recoverySavedConfirm; + + /// No description provided for @settings_cloudSync_encryption_changePassphrase. + /// + /// In en, this message translates to: + /// **'Change passphrase'** + String get settings_cloudSync_encryption_changePassphrase; + + /// No description provided for @settings_cloudSync_encryption_currentPassphrase. + /// + /// In en, this message translates to: + /// **'Current passphrase'** + String get settings_cloudSync_encryption_currentPassphrase; + + /// No description provided for @settings_cloudSync_encryption_newPassphrase. + /// + /// In en, this message translates to: + /// **'New passphrase'** + String get settings_cloudSync_encryption_newPassphrase; + + /// No description provided for @settings_cloudSync_encryption_regenerateRecovery. + /// + /// In en, this message translates to: + /// **'Generate new recovery code'** + String get settings_cloudSync_encryption_regenerateRecovery; + + /// No description provided for @settings_cloudSync_encryption_regenerateRecoveryWarn. + /// + /// In en, this message translates to: + /// **'The old recovery code stops working immediately.'** + String get settings_cloudSync_encryption_regenerateRecoveryWarn; + + /// No description provided for @settings_cloudSync_encryption_disable. + /// + /// In en, this message translates to: + /// **'Turn off encryption'** + String get settings_cloudSync_encryption_disable; + + /// No description provided for @settings_cloudSync_encryption_disableWarn. + /// + /// In en, this message translates to: + /// **'The library will be re-uploaded unencrypted, and other devices will re-download it. Existing encrypted backups stay restorable with the passphrase.'** + String get settings_cloudSync_encryption_disableWarn; + + /// No description provided for @settings_cloudSync_encryption_unlockTitle. + /// + /// In en, this message translates to: + /// **'Enter your encryption passphrase'** + String get settings_cloudSync_encryption_unlockTitle; + + /// No description provided for @settings_cloudSync_encryption_unlockHint. + /// + /// In en, this message translates to: + /// **'Passphrase or recovery code'** + String get settings_cloudSync_encryption_unlockHint; + + /// No description provided for @settings_cloudSync_encryption_unlock. + /// + /// In en, this message translates to: + /// **'Unlock'** + String get settings_cloudSync_encryption_unlock; + + /// No description provided for @settings_cloudSync_encryption_continue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get settings_cloudSync_encryption_continue; + + /// No description provided for @settings_cloudSync_encryption_done. + /// + /// In en, this message translates to: + /// **'Done'** + String get settings_cloudSync_encryption_done; + + /// No description provided for @settings_cloudSync_encryption_cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get settings_cloudSync_encryption_cancel; + /// No description provided for @settings_cloudSync_replace_globalBanner. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 4f5bc5b77..1af7006ab 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -104,6 +104,131 @@ class AppLocalizationsAr extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'غير متوفر في هذا الإصدار — استخدم S3 أو نسخة App Store'; + @override + String get settings_cloudSync_encryption_title => 'التشفير من طرف إلى طرف'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'تشفير جميع بيانات المزامنة والنسخ الاحتياطية السحابية قبل الرفع'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'اختر مزود التخزين السحابي أولاً'; + + @override + String get settings_cloudSync_encryption_statusOff => 'التشفير معطّل'; + + @override + String get settings_cloudSync_encryption_statusOn => 'التشفير مفعّل'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'يتم تشفير بيانات المزامنة والنسخ الاحتياطية السحابية قبل الرفع'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'مشفّر — مطلوب عبارة المرور'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'أدخل عبارة المرور للمزامنة على هذا الجهاز'; + + @override + String get settings_cloudSync_encryption_enable => 'تفعيل التشفير'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'إدخال عبارة المرور'; + + @override + String get settings_cloudSync_encryption_passphrase => 'عبارة المرور'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'تأكيد عبارة المرور'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'عبارتا المرور غير متطابقتين'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'استخدم 8 أحرف على الأقل'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'عبارة مرور أو رمز استرداد غير صحيح'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'يجب تحديث جميع الأجهزة الأخرى إلى أحدث إصدار من التطبيق وستعيد تنزيل المكتبة.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'إذا فقدت عبارة المرور ورمز الاسترداد معًا، فلا يمكن استرداد البيانات في السحابة. بيانات أجهزتك ليست في خطر أبدًا.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'حذف النسخ الاحتياطية السحابية غير المشفرة الموجودة'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => 'رمز الاسترداد'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'دوّن هذا الرمز واحفظه في مكان آمن. إنه الطريقة الوحيدة للعودة إذا نسيت عبارة المرور.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'لقد حفظت رمز الاسترداد الخاص بي'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'تغيير عبارة المرور'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'عبارة المرور الحالية'; + + @override + String get settings_cloudSync_encryption_newPassphrase => + 'عبارة المرور الجديدة'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'إنشاء رمز استرداد جديد'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'يتوقف رمز الاسترداد القديم عن العمل فورًا.'; + + @override + String get settings_cloudSync_encryption_disable => 'إيقاف التشفير'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'سيُعاد رفع المكتبة دون تشفير وستعيد الأجهزة الأخرى تنزيلها. تظل النسخ الاحتياطية المشفرة الموجودة قابلة للاستعادة بعبارة المرور.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'أدخل عبارة مرور التشفير'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'عبارة المرور أو رمز الاسترداد'; + + @override + String get settings_cloudSync_encryption_unlock => 'فتح'; + + @override + String get settings_cloudSync_encryption_continue => 'متابعة'; + + @override + String get settings_cloudSync_encryption_done => 'تم'; + + @override + String get settings_cloudSync_encryption_cancel => 'إلغاء'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'المزامنة متوقفة مؤقتًا — تم استبدال المكتبة من نسخة احتياطية على \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 26d7a50eb..4ba05c912 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -103,6 +103,136 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'In diesem Build nicht verfügbar – verwende S3 oder die App-Store-Version'; + @override + String get settings_cloudSync_encryption_title => + 'Ende-zu-Ende-Verschlüsselung'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Alle Sync-Daten und Cloud-Backups vor dem Hochladen verschlüsseln'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Zuerst einen Cloud-Anbieter auswählen'; + + @override + String get settings_cloudSync_encryption_statusOff => + 'Verschlüsselung ist aus'; + + @override + String get settings_cloudSync_encryption_statusOn => + 'Verschlüsselung ist aktiv'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'Sync-Daten und Cloud-Backups werden vor dem Hochladen verschlüsselt'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Verschlüsselt — Passphrase erforderlich'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Passphrase eingeben, um auf diesem Gerät zu synchronisieren'; + + @override + String get settings_cloudSync_encryption_enable => + 'Verschlüsselung aktivieren'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Passphrase eingeben'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Passphrase'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Passphrase bestätigen'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'Passphrasen stimmen nicht überein'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Mindestens 8 Zeichen verwenden'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Falsche Passphrase oder falscher Wiederherstellungscode'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'Alle anderen Geräte müssen auf die neueste App-Version aktualisiert werden und laden die Bibliothek erneut herunter.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'Gehen Passphrase und Wiederherstellungscode verloren, können die Daten in der Cloud nicht wiederhergestellt werden. Die Daten auf Ihren Geräten sind nie in Gefahr.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Vorhandene unverschlüsselte Cloud-Backups löschen'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => + 'Wiederherstellungscode'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Notieren Sie diesen Code und bewahren Sie ihn sicher auf. Er ist der einzige Weg zurück, falls Sie die Passphrase vergessen.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'Ich habe meinen Wiederherstellungscode gespeichert'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Passphrase ändern'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Aktuelle Passphrase'; + + @override + String get settings_cloudSync_encryption_newPassphrase => 'Neue Passphrase'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Neuen Wiederherstellungscode erzeugen'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'Der alte Wiederherstellungscode wird sofort ungültig.'; + + @override + String get settings_cloudSync_encryption_disable => + 'Verschlüsselung ausschalten'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'Die Bibliothek wird unverschlüsselt neu hochgeladen und andere Geräte laden sie erneut herunter. Vorhandene verschlüsselte Backups bleiben mit der Passphrase wiederherstellbar.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Verschlüsselungs-Passphrase eingeben'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Passphrase oder Wiederherstellungscode'; + + @override + String get settings_cloudSync_encryption_unlock => 'Entsperren'; + + @override + String get settings_cloudSync_encryption_continue => 'Weiter'; + + @override + String get settings_cloudSync_encryption_done => 'Fertig'; + + @override + String get settings_cloudSync_encryption_cancel => 'Abbrechen'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'Synchronisierung pausiert — die Bibliothek wurde aus einem Backup auf \"$deviceName\" ersetzt.'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index b95af04e7..b9a0cbbfa 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -103,6 +103,130 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'Not available in this build — use S3 or the App Store version'; + @override + String get settings_cloudSync_encryption_title => 'End-to-end encryption'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Encrypt all sync data and cloud backups before upload'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Select a cloud provider first'; + + @override + String get settings_cloudSync_encryption_statusOff => 'Encryption is off'; + + @override + String get settings_cloudSync_encryption_statusOn => 'Encryption is on'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'Sync data and cloud backups are encrypted before upload'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Encrypted — passphrase needed'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Enter the passphrase to sync on this device'; + + @override + String get settings_cloudSync_encryption_enable => 'Enable encryption'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Enter passphrase'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Passphrase'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Confirm passphrase'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'Passphrases do not match'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Use at least 8 characters'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Incorrect passphrase or recovery code'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'All other devices must be updated to the latest app version and will re-download the library.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'If you lose both the passphrase and the recovery code, data in the cloud cannot be recovered. Data on your devices is never at risk.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Delete existing unencrypted cloud backups'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => 'Recovery code'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Write this code down and keep it somewhere safe. It is the only way back in if you forget your passphrase.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'I have saved my recovery code'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Change passphrase'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Current passphrase'; + + @override + String get settings_cloudSync_encryption_newPassphrase => 'New passphrase'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Generate new recovery code'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'The old recovery code stops working immediately.'; + + @override + String get settings_cloudSync_encryption_disable => 'Turn off encryption'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'The library will be re-uploaded unencrypted, and other devices will re-download it. Existing encrypted backups stay restorable with the passphrase.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Enter your encryption passphrase'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Passphrase or recovery code'; + + @override + String get settings_cloudSync_encryption_unlock => 'Unlock'; + + @override + String get settings_cloudSync_encryption_continue => 'Continue'; + + @override + String get settings_cloudSync_encryption_done => 'Done'; + + @override + String get settings_cloudSync_encryption_cancel => 'Cancel'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'Sync is paused — the library was replaced from a backup on \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 423b79292..38ca2f4d1 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -103,6 +103,135 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'No disponible en esta versión: usa S3 o la versión de la App Store'; + @override + String get settings_cloudSync_encryption_title => + 'Cifrado de extremo a extremo'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Cifrar todos los datos de sincronización y las copias en la nube antes de subirlos'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Selecciona primero un proveedor de nube'; + + @override + String get settings_cloudSync_encryption_statusOff => + 'El cifrado está desactivado'; + + @override + String get settings_cloudSync_encryption_statusOn => + 'El cifrado está activado'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'Los datos de sincronización y las copias en la nube se cifran antes de subirse'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Cifrado — se necesita la frase de acceso'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Introduce la frase de acceso para sincronizar en este dispositivo'; + + @override + String get settings_cloudSync_encryption_enable => 'Activar el cifrado'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Introducir frase de acceso'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Frase de acceso'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Confirmar frase de acceso'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'Las frases de acceso no coinciden'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Usa al menos 8 caracteres'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Frase de acceso o código de recuperación incorrectos'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'Todos los demás dispositivos deben actualizarse a la última versión de la app y volverán a descargar la biblioteca.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'Si pierdes la frase de acceso y el código de recuperación, los datos en la nube no se podrán recuperar. Los datos en tus dispositivos nunca corren riesgo.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Eliminar las copias en la nube sin cifrar existentes'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => + 'Código de recuperación'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Apunta este código y guárdalo en un lugar seguro. Es la única forma de volver a entrar si olvidas la frase de acceso.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'He guardado mi código de recuperación'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Cambiar frase de acceso'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Frase de acceso actual'; + + @override + String get settings_cloudSync_encryption_newPassphrase => + 'Nueva frase de acceso'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Generar nuevo código de recuperación'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'El código de recuperación anterior deja de funcionar de inmediato.'; + + @override + String get settings_cloudSync_encryption_disable => 'Desactivar el cifrado'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'La biblioteca se volverá a subir sin cifrar y los demás dispositivos la descargarán de nuevo. Las copias cifradas existentes siguen siendo restaurables con la frase de acceso.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Introduce tu frase de acceso de cifrado'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Frase de acceso o código de recuperación'; + + @override + String get settings_cloudSync_encryption_unlock => 'Desbloquear'; + + @override + String get settings_cloudSync_encryption_continue => 'Continuar'; + + @override + String get settings_cloudSync_encryption_done => 'Hecho'; + + @override + String get settings_cloudSync_encryption_cancel => 'Cancelar'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'Sincronización en pausa: la biblioteca se reemplazó desde una copia de seguridad en \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 1327eb26b..6de7b088a 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -103,6 +103,136 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'Indisponible dans cette version — utilisez S3 ou la version de l\'App Store'; + @override + String get settings_cloudSync_encryption_title => + 'Chiffrement de bout en bout'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Chiffrer toutes les données de synchronisation et les sauvegardes cloud avant l\'envoi'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Sélectionnez d\'abord un fournisseur cloud'; + + @override + String get settings_cloudSync_encryption_statusOff => + 'Le chiffrement est désactivé'; + + @override + String get settings_cloudSync_encryption_statusOn => + 'Le chiffrement est activé'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'Les données de synchronisation et les sauvegardes cloud sont chiffrées avant l\'envoi'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Chiffré — phrase secrète requise'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Saisissez la phrase secrète pour synchroniser sur cet appareil'; + + @override + String get settings_cloudSync_encryption_enable => 'Activer le chiffrement'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Saisir la phrase secrète'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Phrase secrète'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Confirmer la phrase secrète'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'Les phrases secrètes ne correspondent pas'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Utilisez au moins 8 caractères'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Phrase secrète ou code de récupération incorrect'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'Tous les autres appareils doivent être mis à jour vers la dernière version de l\'application et retélechargeront la bibliothèque.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'Si vous perdez la phrase secrète et le code de récupération, les données dans le cloud seront irrécupérables. Les données sur vos appareils ne sont jamais menacées.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Supprimer les sauvegardes cloud non chiffrées existantes'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => + 'Code de récupération'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Notez ce code et conservez-le en lieu sûr. C\'est le seul moyen de récupérer l\'accès si vous oubliez la phrase secrète.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'J\'ai enregistré mon code de récupération'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Changer la phrase secrète'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Phrase secrète actuelle'; + + @override + String get settings_cloudSync_encryption_newPassphrase => + 'Nouvelle phrase secrète'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Générer un nouveau code de récupération'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'L\'ancien code de récupération cesse immédiatement de fonctionner.'; + + @override + String get settings_cloudSync_encryption_disable => + 'Désactiver le chiffrement'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'La bibliothèque sera renvoyée non chiffrée et les autres appareils la retéléchargeront. Les sauvegardes chiffrées existantes restent restaurables avec la phrase secrète.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Saisissez votre phrase secrète de chiffrement'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Phrase secrète ou code de récupération'; + + @override + String get settings_cloudSync_encryption_unlock => 'Déverrouiller'; + + @override + String get settings_cloudSync_encryption_continue => 'Continuer'; + + @override + String get settings_cloudSync_encryption_done => 'Terminé'; + + @override + String get settings_cloudSync_encryption_cancel => 'Annuler'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'Synchronisation en pause — la bibliothèque a été remplacée depuis une sauvegarde sur \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 1fef7148a..20e7b5fc2 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -102,6 +102,129 @@ class AppLocalizationsHe extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'לא זמין בגרסה זו — השתמש ב-S3 או בגרסת App Store'; + @override + String get settings_cloudSync_encryption_title => 'הצפנה מקצה לקצה'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'הצפנת כל נתוני הסנכרון והגיבויים בענן לפני ההעלאה'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'בחרו קודם ספק ענן'; + + @override + String get settings_cloudSync_encryption_statusOff => 'ההצפנה כבויה'; + + @override + String get settings_cloudSync_encryption_statusOn => 'ההצפנה פועלת'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'נתוני סנכרון וגיבויים בענן מוצפנים לפני ההעלאה'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'מוצפן — נדרש משפט סיסמה'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'הזינו את משפט הסיסמה כדי לסנכרן במכשיר זה'; + + @override + String get settings_cloudSync_encryption_enable => 'הפעלת הצפנה'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => 'הזנת משפט סיסמה'; + + @override + String get settings_cloudSync_encryption_passphrase => 'משפט סיסמה'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'אישור משפט הסיסמה'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'משפטי הסיסמה אינם תואמים'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'השתמשו ב-8 תווים לפחות'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'משפט סיסמה או קוד שחזור שגויים'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'יש לעדכן את כל שאר המכשירים לגרסת האפליקציה האחרונה והם יורידו מחדש את הספרייה.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'אם תאבדו גם את משפט הסיסמה וגם את קוד השחזור, לא ניתן יהיה לשחזר את הנתונים בענן. הנתונים במכשירים שלכם לעולם אינם בסיכון.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'מחיקת גיבויי ענן לא מוצפנים קיימים'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => 'קוד שחזור'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'רשמו את הקוד הזה ושמרו אותו במקום בטוח. זו הדרך היחידה לחזור אם תשכחו את משפט הסיסמה.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'שמרתי את קוד השחזור שלי'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'שינוי משפט סיסמה'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'משפט הסיסמה הנוכחי'; + + @override + String get settings_cloudSync_encryption_newPassphrase => 'משפט סיסמה חדש'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'יצירת קוד שחזור חדש'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'קוד השחזור הישן מפסיק לפעול מיד.'; + + @override + String get settings_cloudSync_encryption_disable => 'כיבוי ההצפנה'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'הספרייה תועלה מחדש ללא הצפנה ושאר המכשירים יורידו אותה שוב. גיבויים מוצפנים קיימים יישארו ניתנים לשחזור עם משפט הסיסמה.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'הזינו את משפט הסיסמה של ההצפנה'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'משפט סיסמה או קוד שחזור'; + + @override + String get settings_cloudSync_encryption_unlock => 'ביטול נעילה'; + + @override + String get settings_cloudSync_encryption_continue => 'המשך'; + + @override + String get settings_cloudSync_encryption_done => 'סיום'; + + @override + String get settings_cloudSync_encryption_cancel => 'ביטול'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'הסנכרון מושהה — הספרייה הוחלפה מגיבוי במכשיר \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index f3a77e5cf..f462b3bbe 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -104,6 +104,134 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'Ebben a buildben nem érhető el – használj S3-at vagy az App Store-verziót'; + @override + String get settings_cloudSync_encryption_title => + 'Végpontok közötti titkosítás'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Minden szinkronizálási adat és felhőmentés titkosítása feltöltés előtt'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Előbb válasszon felhőszolgáltatót'; + + @override + String get settings_cloudSync_encryption_statusOff => + 'A titkosítás ki van kapcsolva'; + + @override + String get settings_cloudSync_encryption_statusOn => + 'A titkosítás be van kapcsolva'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'A szinkronizálási adatok és a felhőmentések feltöltés előtt titkosítva lesznek'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Titkosítva — jelmondat szükséges'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Adja meg a jelmondatot a szinkronizáláshoz ezen az eszközön'; + + @override + String get settings_cloudSync_encryption_enable => 'Titkosítás bekapcsolása'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Jelmondat megadása'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Jelmondat'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Jelmondat megerősítése'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'A jelmondatok nem egyeznek'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Legalább 8 karaktert használjon'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Hibás jelmondat vagy helyreállítási kód'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'Minden más eszközt a legújabb alkalmazásverzióra kell frissíteni, és újra letöltik a könyvtárat.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'Ha a jelmondat és a helyreállítási kód is elvész, a felhőben lévő adatok nem állíthatók helyre. Az eszközein lévő adatok soha nincsenek veszélyben.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Meglévő titkosítatlan felhőmentések törlése'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => + 'Helyreállítási kód'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Írja fel ezt a kódot, és őrizze biztonságos helyen. Ez az egyetlen visszaút, ha elfelejti a jelmondatot.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'Elmentettem a helyreállítási kódomat'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Jelmondat módosítása'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Jelenlegi jelmondat'; + + @override + String get settings_cloudSync_encryption_newPassphrase => 'Új jelmondat'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Új helyreállítási kód létrehozása'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'A régi helyreállítási kód azonnal érvénytelenné válik.'; + + @override + String get settings_cloudSync_encryption_disable => 'Titkosítás kikapcsolása'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'A könyvtár titkosítatlanul lesz újra feltöltve, és a többi eszköz újra letölti. A meglévő titkosított mentések a jelmondattal továbbra is visszaállíthatók.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Adja meg a titkosítási jelmondatot'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Jelmondat vagy helyreállítási kód'; + + @override + String get settings_cloudSync_encryption_unlock => 'Feloldás'; + + @override + String get settings_cloudSync_encryption_continue => 'Folytatás'; + + @override + String get settings_cloudSync_encryption_done => 'Kész'; + + @override + String get settings_cloudSync_encryption_cancel => 'Mégse'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'A szinkronizálás szünetel — a könyvtárat egy biztonsági másolatból cserélték itt: \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 2439c0d66..ab72bf0a9 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -103,6 +103,134 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'Non disponibile in questa build: usa S3 o la versione dell\'App Store'; + @override + String get settings_cloudSync_encryption_title => 'Crittografia end-to-end'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Cripta tutti i dati di sincronizzazione e i backup cloud prima del caricamento'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Seleziona prima un provider cloud'; + + @override + String get settings_cloudSync_encryption_statusOff => + 'La crittografia è disattivata'; + + @override + String get settings_cloudSync_encryption_statusOn => + 'La crittografia è attiva'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'I dati di sincronizzazione e i backup cloud vengono criptati prima del caricamento'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Criptato — serve la passphrase'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Inserisci la passphrase per sincronizzare su questo dispositivo'; + + @override + String get settings_cloudSync_encryption_enable => 'Attiva la crittografia'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Inserisci la passphrase'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Passphrase'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Conferma la passphrase'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'Le passphrase non coincidono'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Usa almeno 8 caratteri'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Passphrase o codice di recupero errati'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'Tutti gli altri dispositivi devono essere aggiornati all\'ultima versione dell\'app e scaricheranno di nuovo la libreria.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'Se perdi sia la passphrase sia il codice di recupero, i dati nel cloud non potranno essere recuperati. I dati sui tuoi dispositivi non sono mai a rischio.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Elimina i backup cloud non criptati esistenti'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => + 'Codice di recupero'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Annota questo codice e conservalo al sicuro. È l\'unico modo per rientrare se dimentichi la passphrase.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'Ho salvato il mio codice di recupero'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Cambia passphrase'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Passphrase attuale'; + + @override + String get settings_cloudSync_encryption_newPassphrase => 'Nuova passphrase'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Genera un nuovo codice di recupero'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'Il vecchio codice di recupero smette subito di funzionare.'; + + @override + String get settings_cloudSync_encryption_disable => + 'Disattiva la crittografia'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'La libreria verrà ricaricata non criptata e gli altri dispositivi la scaricheranno di nuovo. I backup criptati esistenti restano ripristinabili con la passphrase.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Inserisci la passphrase di crittografia'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Passphrase o codice di recupero'; + + @override + String get settings_cloudSync_encryption_unlock => 'Sblocca'; + + @override + String get settings_cloudSync_encryption_continue => 'Continua'; + + @override + String get settings_cloudSync_encryption_done => 'Fatto'; + + @override + String get settings_cloudSync_encryption_cancel => 'Annulla'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'Sincronizzazione in pausa: la libreria è stata sostituita da un backup su \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 0f27547aa..c5fef8652 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -103,6 +103,135 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'Niet beschikbaar in deze build — gebruik S3 of de App Store-versie'; + @override + String get settings_cloudSync_encryption_title => 'End-to-end-versleuteling'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Versleutel alle synchronisatiegegevens en cloudback-ups vóór het uploaden'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Selecteer eerst een cloudprovider'; + + @override + String get settings_cloudSync_encryption_statusOff => + 'Versleuteling staat uit'; + + @override + String get settings_cloudSync_encryption_statusOn => + 'Versleuteling staat aan'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'Synchronisatiegegevens en cloudback-ups worden vóór het uploaden versleuteld'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Versleuteld — wachtwoordzin vereist'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Voer de wachtwoordzin in om op dit apparaat te synchroniseren'; + + @override + String get settings_cloudSync_encryption_enable => + 'Versleuteling inschakelen'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Wachtwoordzin invoeren'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Wachtwoordzin'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Wachtwoordzin bevestigen'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'Wachtwoordzinnen komen niet overeen'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Gebruik minstens 8 tekens'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Onjuiste wachtwoordzin of herstelcode'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'Alle andere apparaten moeten naar de nieuwste appversie worden bijgewerkt en downloaden de bibliotheek opnieuw.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'Als u zowel de wachtwoordzin als de herstelcode verliest, zijn de gegevens in de cloud niet te herstellen. De gegevens op uw apparaten lopen nooit gevaar.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Bestaande onversleutelde cloudback-ups verwijderen'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => 'Herstelcode'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Noteer deze code en bewaar hem veilig. Het is de enige weg terug als u de wachtwoordzin vergeet.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'Ik heb mijn herstelcode opgeslagen'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Wachtwoordzin wijzigen'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Huidige wachtwoordzin'; + + @override + String get settings_cloudSync_encryption_newPassphrase => + 'Nieuwe wachtwoordzin'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Nieuwe herstelcode genereren'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'De oude herstelcode werkt direct niet meer.'; + + @override + String get settings_cloudSync_encryption_disable => + 'Versleuteling uitschakelen'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'De bibliotheek wordt onversleuteld opnieuw geüpload en andere apparaten downloaden hem opnieuw. Bestaande versleutelde back-ups blijven met de wachtwoordzin te herstellen.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Voer uw versleutelings-wachtwoordzin in'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Wachtwoordzin of herstelcode'; + + @override + String get settings_cloudSync_encryption_unlock => 'Ontgrendelen'; + + @override + String get settings_cloudSync_encryption_continue => 'Doorgaan'; + + @override + String get settings_cloudSync_encryption_done => 'Klaar'; + + @override + String get settings_cloudSync_encryption_cancel => 'Annuleren'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'Synchronisatie onderbroken — de bibliotheek is vervangen vanaf een back-up op \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 32953c7e3..b527975e4 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -104,6 +104,134 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => 'Indisponível nesta versão — use o S3 ou a versão da App Store'; + @override + String get settings_cloudSync_encryption_title => + 'Criptografia de ponta a ponta'; + + @override + String get settings_cloudSync_encryption_subtitleOff => + 'Criptografar todos os dados de sincronização e backups na nuvem antes do envio'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => + 'Selecione primeiro um provedor de nuvem'; + + @override + String get settings_cloudSync_encryption_statusOff => + 'A criptografia está desativada'; + + @override + String get settings_cloudSync_encryption_statusOn => + 'A criptografia está ativada'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + 'Dados de sincronização e backups na nuvem são criptografados antes do envio'; + + @override + String get settings_cloudSync_encryption_statusLocked => + 'Criptografado — senha necessária'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + 'Digite a frase-senha para sincronizar neste dispositivo'; + + @override + String get settings_cloudSync_encryption_enable => 'Ativar criptografia'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => + 'Digitar frase-senha'; + + @override + String get settings_cloudSync_encryption_passphrase => 'Frase-senha'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => + 'Confirmar frase-senha'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => + 'As frases-senha não coincidem'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => + 'Use pelo menos 8 caracteres'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => + 'Frase-senha ou código de recuperação incorretos'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + 'Todos os outros dispositivos precisam ser atualizados para a versão mais recente do app e baixarão a biblioteca novamente.'; + + @override + String get settings_cloudSync_encryption_warnLoss => + 'Se você perder a frase-senha e o código de recuperação, os dados na nuvem não poderão ser recuperados. Os dados nos seus dispositivos nunca correm risco.'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + 'Excluir backups na nuvem não criptografados existentes'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => + 'Código de recuperação'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + 'Anote este código e guarde-o em local seguro. É a única forma de voltar a acessar se você esquecer a frase-senha.'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => + 'Salvei meu código de recuperação'; + + @override + String get settings_cloudSync_encryption_changePassphrase => + 'Alterar frase-senha'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => + 'Frase-senha atual'; + + @override + String get settings_cloudSync_encryption_newPassphrase => 'Nova frase-senha'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => + 'Gerar novo código de recuperação'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + 'O código de recuperação antigo deixa de funcionar imediatamente.'; + + @override + String get settings_cloudSync_encryption_disable => 'Desativar criptografia'; + + @override + String get settings_cloudSync_encryption_disableWarn => + 'A biblioteca será reenviada sem criptografia e os outros dispositivos a baixarão novamente. Os backups criptografados existentes continuam restauráveis com a frase-senha.'; + + @override + String get settings_cloudSync_encryption_unlockTitle => + 'Digite sua frase-senha de criptografia'; + + @override + String get settings_cloudSync_encryption_unlockHint => + 'Frase-senha ou código de recuperação'; + + @override + String get settings_cloudSync_encryption_unlock => 'Desbloquear'; + + @override + String get settings_cloudSync_encryption_continue => 'Continuar'; + + @override + String get settings_cloudSync_encryption_done => 'Concluído'; + + @override + String get settings_cloudSync_encryption_cancel => 'Cancelar'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return 'Sincronização em pausa — a biblioteca foi substituída a partir de uma cópia de segurança em \"$deviceName\".'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index aa410917e..6ec5ad9a9 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -99,6 +99,116 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_cloudSync_provider_icloud_unsupportedSubtitle => '此版本不可用 — 请使用 S3 或 App Store 版本'; + @override + String get settings_cloudSync_encryption_title => '端到端加密'; + + @override + String get settings_cloudSync_encryption_subtitleOff => '上传前加密所有同步数据和云备份'; + + @override + String get settings_cloudSync_encryption_subtitleNeedsProvider => '请先选择云服务商'; + + @override + String get settings_cloudSync_encryption_statusOff => '加密已关闭'; + + @override + String get settings_cloudSync_encryption_statusOn => '加密已开启'; + + @override + String get settings_cloudSync_encryption_statusOnSubtitle => + '同步数据和云备份在上传前会被加密'; + + @override + String get settings_cloudSync_encryption_statusLocked => '已加密 — 需要口令'; + + @override + String get settings_cloudSync_encryption_statusLockedSubtitle => + '输入口令以在此设备上同步'; + + @override + String get settings_cloudSync_encryption_enable => '开启加密'; + + @override + String get settings_cloudSync_encryption_enterPassphrase => '输入口令'; + + @override + String get settings_cloudSync_encryption_passphrase => '口令'; + + @override + String get settings_cloudSync_encryption_passphraseConfirm => '确认口令'; + + @override + String get settings_cloudSync_encryption_passphraseMismatch => '两次输入的口令不一致'; + + @override + String get settings_cloudSync_encryption_passphraseTooShort => '请至少使用 8 个字符'; + + @override + String get settings_cloudSync_encryption_wrongPassphrase => '口令或恢复码不正确'; + + @override + String get settings_cloudSync_encryption_warnUpdateDevices => + '所有其他设备都必须更新到最新版应用,并将重新下载资料库。'; + + @override + String get settings_cloudSync_encryption_warnLoss => + '如果口令和恢复码都丢失,云端数据将无法恢复。设备上的数据永远不会有风险。'; + + @override + String get settings_cloudSync_encryption_deletePlaintextBackups => + '删除现有的未加密云备份'; + + @override + String get settings_cloudSync_encryption_recoveryTitle => '恢复码'; + + @override + String get settings_cloudSync_encryption_recoveryExplain => + '请抄写此恢复码并妥善保管。如果忘记口令,这是唯一的恢复途径。'; + + @override + String get settings_cloudSync_encryption_recoverySavedConfirm => '我已保存恢复码'; + + @override + String get settings_cloudSync_encryption_changePassphrase => '更改口令'; + + @override + String get settings_cloudSync_encryption_currentPassphrase => '当前口令'; + + @override + String get settings_cloudSync_encryption_newPassphrase => '新口令'; + + @override + String get settings_cloudSync_encryption_regenerateRecovery => '生成新的恢复码'; + + @override + String get settings_cloudSync_encryption_regenerateRecoveryWarn => + '旧的恢复码将立即失效。'; + + @override + String get settings_cloudSync_encryption_disable => '关闭加密'; + + @override + String get settings_cloudSync_encryption_disableWarn => + '资料库将以未加密方式重新上传,其他设备将重新下载。现有的加密备份仍可用口令恢复。'; + + @override + String get settings_cloudSync_encryption_unlockTitle => '输入您的加密口令'; + + @override + String get settings_cloudSync_encryption_unlockHint => '口令或恢复码'; + + @override + String get settings_cloudSync_encryption_unlock => '解锁'; + + @override + String get settings_cloudSync_encryption_continue => '继续'; + + @override + String get settings_cloudSync_encryption_done => '完成'; + + @override + String get settings_cloudSync_encryption_cancel => '取消'; + @override String settings_cloudSync_replace_globalBanner(String deviceName) { return '同步已暂停 — 资料库已从 \"$deviceName\" 上的备份替换。'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 3e51d7229..cd31fb270 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "iCloud-synchronisatie is niet beschikbaar in deze build van Submersion. Gebruik S3-synchronisatie of de App Store-versie.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "Niet beschikbaar in deze build — gebruik S3 of de App Store-versie", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "End-to-end-versleuteling", + "settings_cloudSync_encryption_subtitleOff": "Versleutel alle synchronisatiegegevens en cloudback-ups vóór het uploaden", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Selecteer eerst een cloudprovider", + "settings_cloudSync_encryption_statusOff": "Versleuteling staat uit", + "settings_cloudSync_encryption_statusOn": "Versleuteling staat aan", + "settings_cloudSync_encryption_statusOnSubtitle": "Synchronisatiegegevens en cloudback-ups worden vóór het uploaden versleuteld", + "settings_cloudSync_encryption_statusLocked": "Versleuteld — wachtwoordzin vereist", + "settings_cloudSync_encryption_statusLockedSubtitle": "Voer de wachtwoordzin in om op dit apparaat te synchroniseren", + "settings_cloudSync_encryption_enable": "Versleuteling inschakelen", + "settings_cloudSync_encryption_enterPassphrase": "Wachtwoordzin invoeren", + "settings_cloudSync_encryption_passphrase": "Wachtwoordzin", + "settings_cloudSync_encryption_passphraseConfirm": "Wachtwoordzin bevestigen", + "settings_cloudSync_encryption_passphraseMismatch": "Wachtwoordzinnen komen niet overeen", + "settings_cloudSync_encryption_passphraseTooShort": "Gebruik minstens 8 tekens", + "settings_cloudSync_encryption_wrongPassphrase": "Onjuiste wachtwoordzin of herstelcode", + "settings_cloudSync_encryption_warnUpdateDevices": "Alle andere apparaten moeten naar de nieuwste appversie worden bijgewerkt en downloaden de bibliotheek opnieuw.", + "settings_cloudSync_encryption_warnLoss": "Als u zowel de wachtwoordzin als de herstelcode verliest, zijn de gegevens in de cloud niet te herstellen. De gegevens op uw apparaten lopen nooit gevaar.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Bestaande onversleutelde cloudback-ups verwijderen", + "settings_cloudSync_encryption_recoveryTitle": "Herstelcode", + "settings_cloudSync_encryption_recoveryExplain": "Noteer deze code en bewaar hem veilig. Het is de enige weg terug als u de wachtwoordzin vergeet.", + "settings_cloudSync_encryption_recoverySavedConfirm": "Ik heb mijn herstelcode opgeslagen", + "settings_cloudSync_encryption_changePassphrase": "Wachtwoordzin wijzigen", + "settings_cloudSync_encryption_currentPassphrase": "Huidige wachtwoordzin", + "settings_cloudSync_encryption_newPassphrase": "Nieuwe wachtwoordzin", + "settings_cloudSync_encryption_regenerateRecovery": "Nieuwe herstelcode genereren", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "De oude herstelcode werkt direct niet meer.", + "settings_cloudSync_encryption_disable": "Versleuteling uitschakelen", + "settings_cloudSync_encryption_disableWarn": "De bibliotheek wordt onversleuteld opnieuw geüpload en andere apparaten downloaden hem opnieuw. Bestaande versleutelde back-ups blijven met de wachtwoordzin te herstellen.", + "settings_cloudSync_encryption_unlockTitle": "Voer uw versleutelings-wachtwoordzin in", + "settings_cloudSync_encryption_unlockHint": "Wachtwoordzin of herstelcode", + "settings_cloudSync_encryption_unlock": "Ontgrendelen", + "settings_cloudSync_encryption_continue": "Doorgaan", + "settings_cloudSync_encryption_done": "Klaar", + "settings_cloudSync_encryption_cancel": "Annuleren", "@@locale": "nl", "settings_cloudSync_replace_globalBanner": "Synchronisatie onderbroken — de bibliotheek is vervangen vanaf een back-up op \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "Je herstelde bibliotheek wordt met de cloud gesynchroniseerd…", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 36fa873d2..936d7a5b2 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -28,6 +28,40 @@ "settings_cloudSync_error_icloudUnsupported": "A sincronização do iCloud não está disponível nesta versão do Submersion. Use a sincronização S3 ou a versão da App Store.", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "Indisponível nesta versão — use o S3 ou a versão da App Store", "@@last_modified": "2026-06-15", + "settings_cloudSync_encryption_title": "Criptografia de ponta a ponta", + "settings_cloudSync_encryption_subtitleOff": "Criptografar todos os dados de sincronização e backups na nuvem antes do envio", + "settings_cloudSync_encryption_subtitleNeedsProvider": "Selecione primeiro um provedor de nuvem", + "settings_cloudSync_encryption_statusOff": "A criptografia está desativada", + "settings_cloudSync_encryption_statusOn": "A criptografia está ativada", + "settings_cloudSync_encryption_statusOnSubtitle": "Dados de sincronização e backups na nuvem são criptografados antes do envio", + "settings_cloudSync_encryption_statusLocked": "Criptografado — senha necessária", + "settings_cloudSync_encryption_statusLockedSubtitle": "Digite a frase-senha para sincronizar neste dispositivo", + "settings_cloudSync_encryption_enable": "Ativar criptografia", + "settings_cloudSync_encryption_enterPassphrase": "Digitar frase-senha", + "settings_cloudSync_encryption_passphrase": "Frase-senha", + "settings_cloudSync_encryption_passphraseConfirm": "Confirmar frase-senha", + "settings_cloudSync_encryption_passphraseMismatch": "As frases-senha não coincidem", + "settings_cloudSync_encryption_passphraseTooShort": "Use pelo menos 8 caracteres", + "settings_cloudSync_encryption_wrongPassphrase": "Frase-senha ou código de recuperação incorretos", + "settings_cloudSync_encryption_warnUpdateDevices": "Todos os outros dispositivos precisam ser atualizados para a versão mais recente do app e baixarão a biblioteca novamente.", + "settings_cloudSync_encryption_warnLoss": "Se você perder a frase-senha e o código de recuperação, os dados na nuvem não poderão ser recuperados. Os dados nos seus dispositivos nunca correm risco.", + "settings_cloudSync_encryption_deletePlaintextBackups": "Excluir backups na nuvem não criptografados existentes", + "settings_cloudSync_encryption_recoveryTitle": "Código de recuperação", + "settings_cloudSync_encryption_recoveryExplain": "Anote este código e guarde-o em local seguro. É a única forma de voltar a acessar se você esquecer a frase-senha.", + "settings_cloudSync_encryption_recoverySavedConfirm": "Salvei meu código de recuperação", + "settings_cloudSync_encryption_changePassphrase": "Alterar frase-senha", + "settings_cloudSync_encryption_currentPassphrase": "Frase-senha atual", + "settings_cloudSync_encryption_newPassphrase": "Nova frase-senha", + "settings_cloudSync_encryption_regenerateRecovery": "Gerar novo código de recuperação", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "O código de recuperação antigo deixa de funcionar imediatamente.", + "settings_cloudSync_encryption_disable": "Desativar criptografia", + "settings_cloudSync_encryption_disableWarn": "A biblioteca será reenviada sem criptografia e os outros dispositivos a baixarão novamente. Os backups criptografados existentes continuam restauráveis com a frase-senha.", + "settings_cloudSync_encryption_unlockTitle": "Digite sua frase-senha de criptografia", + "settings_cloudSync_encryption_unlockHint": "Frase-senha ou código de recuperação", + "settings_cloudSync_encryption_unlock": "Desbloquear", + "settings_cloudSync_encryption_continue": "Continuar", + "settings_cloudSync_encryption_done": "Concluído", + "settings_cloudSync_encryption_cancel": "Cancelar", "@@locale": "pt", "settings_cloudSync_replace_globalBanner": "Sincronização em pausa — a biblioteca foi substituída a partir de uma cópia de segurança em \"{deviceName}\".", "settings_cloudSync_postRestore_syncing": "A sincronizar a sua biblioteca restaurada com a nuvem…", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 574503de7..552c183d3 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -27,6 +27,40 @@ "settings_cloudSync_error_icloudUnknown": "无法连接 iCloud。请重试。", "settings_cloudSync_error_icloudUnsupported": "此 Submersion 版本不支持 iCloud 同步。请使用 S3 同步或 App Store 版本。", "settings_cloudSync_provider_icloud_unsupportedSubtitle": "此版本不可用 — 请使用 S3 或 App Store 版本", + "settings_cloudSync_encryption_title": "端到端加密", + "settings_cloudSync_encryption_subtitleOff": "上传前加密所有同步数据和云备份", + "settings_cloudSync_encryption_subtitleNeedsProvider": "请先选择云服务商", + "settings_cloudSync_encryption_statusOff": "加密已关闭", + "settings_cloudSync_encryption_statusOn": "加密已开启", + "settings_cloudSync_encryption_statusOnSubtitle": "同步数据和云备份在上传前会被加密", + "settings_cloudSync_encryption_statusLocked": "已加密 — 需要口令", + "settings_cloudSync_encryption_statusLockedSubtitle": "输入口令以在此设备上同步", + "settings_cloudSync_encryption_enable": "开启加密", + "settings_cloudSync_encryption_enterPassphrase": "输入口令", + "settings_cloudSync_encryption_passphrase": "口令", + "settings_cloudSync_encryption_passphraseConfirm": "确认口令", + "settings_cloudSync_encryption_passphraseMismatch": "两次输入的口令不一致", + "settings_cloudSync_encryption_passphraseTooShort": "请至少使用 8 个字符", + "settings_cloudSync_encryption_wrongPassphrase": "口令或恢复码不正确", + "settings_cloudSync_encryption_warnUpdateDevices": "所有其他设备都必须更新到最新版应用,并将重新下载资料库。", + "settings_cloudSync_encryption_warnLoss": "如果口令和恢复码都丢失,云端数据将无法恢复。设备上的数据永远不会有风险。", + "settings_cloudSync_encryption_deletePlaintextBackups": "删除现有的未加密云备份", + "settings_cloudSync_encryption_recoveryTitle": "恢复码", + "settings_cloudSync_encryption_recoveryExplain": "请抄写此恢复码并妥善保管。如果忘记口令,这是唯一的恢复途径。", + "settings_cloudSync_encryption_recoverySavedConfirm": "我已保存恢复码", + "settings_cloudSync_encryption_changePassphrase": "更改口令", + "settings_cloudSync_encryption_currentPassphrase": "当前口令", + "settings_cloudSync_encryption_newPassphrase": "新口令", + "settings_cloudSync_encryption_regenerateRecovery": "生成新的恢复码", + "settings_cloudSync_encryption_regenerateRecoveryWarn": "旧的恢复码将立即失效。", + "settings_cloudSync_encryption_disable": "关闭加密", + "settings_cloudSync_encryption_disableWarn": "资料库将以未加密方式重新上传,其他设备将重新下载。现有的加密备份仍可用口令恢复。", + "settings_cloudSync_encryption_unlockTitle": "输入您的加密口令", + "settings_cloudSync_encryption_unlockHint": "口令或恢复码", + "settings_cloudSync_encryption_unlock": "解锁", + "settings_cloudSync_encryption_continue": "继续", + "settings_cloudSync_encryption_done": "完成", + "settings_cloudSync_encryption_cancel": "取消", "@@locale": "zh", "settings_cloudSync_replace_globalBanner": "同步已暂停 — 资料库已从 \"{deviceName}\" 上的备份替换。", "settings_cloudSync_postRestore_syncing": "正在将恢复的资料库与云同步…", diff --git a/pubspec.lock b/pubspec.lock index ff4c9efaa..dd8c35b92 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,6 +304,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + cryptography_flutter: + dependency: "direct main" + description: + name: cryptography_flutter + sha256: d1c7e7a31a072d63b27ce0537b89868f9bda9188f2b1651ae728a295762921d4 + url: "https://pub.dev" + source: hosted + version: "2.3.4" csslib: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bf080e36c..a45e3da2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -118,6 +118,8 @@ dependencies: timezone: ^0.11.0 google_fonts: ^8.0.2 linked_scroll_controller: ^0.2.0 + cryptography: ^2.9.0 + cryptography_flutter: ^2.3.4 dev_dependencies: flutter_test: diff --git a/scripts/generate_crypto_test_vectors.py b/scripts/generate_crypto_test_vectors.py new file mode 100644 index 000000000..c9c33eba5 --- /dev/null +++ b/scripts/generate_crypto_test_vectors.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Generates known-answer test vectors for Submersion's SBE1 crypto. + +Output: test/fixtures/crypto/crypto_vectors.json +All values are base64 unless noted. Deterministic (fixed inputs). +""" +import base64 +import gzip +import json +import uuid + +from argon2.low_level import Type, hash_secret_raw +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes + + +def b64(b: bytes) -> str: + return base64.b64encode(b).decode() + + +KEY = bytes(range(32)) # 000102...1f +NONCE = bytes(range(12)) # 000102...0b +KEY_ID = uuid.UUID("8f14e45f-ceea-467f-ab37-a10a8d5f4c11") +FILENAME = "ssv1.devA.cs.00042.json" +PLAINTEXT = b'{"hello":"submersion","n":42}' + +# --- raw AES-256-GCM --- +gcm = AESGCM(KEY) +ct = gcm.encrypt(NONCE, PLAINTEXT, FILENAME.encode()) # ct||tag + +# --- HKDF-SHA256 (data key derivation) --- +hkdf_out = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, + info=b"sbe:v1:data").derive(KEY) + +# --- Argon2id (KDF for keyslots) --- small params so tests stay fast +ARGON_SALT = bytes(range(16)) +argon_out = hash_secret_raw( + secret=b"correct horse battery staple", salt=ARGON_SALT, + time_cost=3, memory_cost=1024, parallelism=1, hash_len=32, + type=Type.ID) + +# --- full SBE1 single-shot envelope (gzip off), built byte-for-byte --- +header = b"SBE1" + KEY_ID.bytes + bytes([0]) + NONCE +env_plain = header + ct + +# --- full SBE1 single-shot envelope (gzip on) --- +gz = gzip.compress(PLAINTEXT, mtime=0) +ct_gz = AESGCM(KEY).encrypt(NONCE, gz, FILENAME.encode()) +env_gzip = b"SBE1" + KEY_ID.bytes + bytes([1]) + NONCE + ct_gz + +vectors = { + "aesGcm": {"key": b64(KEY), "nonce": b64(NONCE), + "aad": FILENAME, "plaintext": b64(PLAINTEXT), + "ciphertextWithTag": b64(ct)}, + "hkdfData": {"ikm": b64(KEY), "info": "sbe:v1:data", + "output": b64(hkdf_out)}, + "argon2id": {"password": "correct horse battery staple", + "salt": b64(ARGON_SALT), "m": 1024, "t": 3, "p": 1, + "output": b64(argon_out)}, + "envelopePlain": {"key": b64(KEY), "keyId": str(KEY_ID), + "filename": FILENAME, "plaintext": b64(PLAINTEXT), + "envelope": b64(env_plain)}, + "envelopeGzip": {"key": b64(KEY), "keyId": str(KEY_ID), + "filename": FILENAME, "plaintext": b64(PLAINTEXT), + "envelope": b64(env_gzip)}, +} +with open("test/fixtures/crypto/crypto_vectors.json", "w") as f: + json.dump(vectors, f, indent=2) +print("wrote test/fixtures/crypto/crypto_vectors.json") diff --git a/scripts/generate_eff_wordlist.py b/scripts/generate_eff_wordlist.py new file mode 100644 index 000000000..e41c978bb --- /dev/null +++ b/scripts/generate_eff_wordlist.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Fetches the EFF short wordlist (1296 words) and emits a Dart constant. + +Output: lib/core/services/sync/crypto/eff_short_wordlist.dart +""" +import urllib.request + +URL = "https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt" + +raw = urllib.request.urlopen(URL).read().decode() +words = [] +for line in raw.strip().splitlines(): + # lines look like: "1111\tacid" + parts = line.split() + words.append(parts[-1]) +assert len(words) == 1296, f"expected 1296 words, got {len(words)}" +assert len(set(words)) == 1296, "duplicate words" + +with open("lib/core/services/sync/crypto/eff_short_wordlist.dart", "w") as f: + f.write("/// EFF short wordlist #1 (1296 words), for recovery codes.\n") + f.write("/// Generated by scripts/generate_eff_wordlist.py. Do not edit.\n") + f.write("/// Source: https://www.eff.org/dice\n") + f.write("const List effShortWordlist = [\n") + for w in words: + f.write(f" '{w}',\n") + f.write("];\n") +print("wrote eff_short_wordlist.dart with", len(words), "words") diff --git a/test/core/services/cloud_storage/encrypting_cloud_storage_provider_test.dart b/test/core/services/cloud_storage/encrypting_cloud_storage_provider_test.dart new file mode 100644 index 000000000..ac58c6607 --- /dev/null +++ b/test/core/services/cloud_storage/encrypting_cloud_storage_provider_test.dart @@ -0,0 +1,214 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/encrypting_cloud_storage_provider.dart'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + +import '../../../support/fake_cloud_storage_provider.dart'; + +const _keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; + +void main() { + late FakeCloudStorageProvider inner; + late EncryptingCloudStorageProvider provider; + final dataKey = SecretKey(List.generate(32, (i) => i + 1)); + + setUp(() { + inner = FakeCloudStorageProvider(); + provider = EncryptingCloudStorageProvider( + inner, + dataKey: dataKey, + libraryKeyId: _keyId, + ); + }); + + Uint8List bytesOf(String s) => Uint8List.fromList(utf8.encode(s)); + + test('uploads are SBE1 at rest, downloads round-trip plaintext', () async { + final up = await provider.uploadFile( + bytesOf('{"cs":1}'), + 'ssv1.devA.cs.00001.json', + ); + // At rest (inner fake) the bytes must be an envelope: + final atRest = await inner.downloadFile(up.fileId); + expect(SyncEnvelope.hasMagic(atRest), isTrue); + // Through the decorator they come back as plaintext: + final roundTrip = await provider.downloadFile(up.fileId); + expect(utf8.decode(roundTrip), '{"cs":1}'); + }); + + test('keyslot file and backup artifacts are exempt', () async { + expect( + EncryptingCloudStorageProvider.isExempt(KeyslotFile.cloudFileName), + isTrue, + ); + expect( + EncryptingCloudStorageProvider.isExempt( + 'submersion_backup_2026-07-10.sbe', + ), + isTrue, + ); + expect( + EncryptingCloudStorageProvider.isExempt('ssv1.devA.manifest.json'), + isFalse, + ); + + final up = await provider.uploadFile( + bytesOf('{"slots":[]}'), + KeyslotFile.cloudFileName, + ); + final atRest = await inner.downloadFile(up.fileId); + expect(SyncEnvelope.hasMagic(atRest), isFalse); + expect(utf8.decode(atRest), '{"slots":[]}'); + }); + + test('plaintext files pass through downloads unchanged', () async { + final up = await inner.uploadFile( + bytesOf('{"legacy":true}'), + 'submersion_library_epoch.json', + ); + final viaDecorator = await provider.downloadFile(up.fileId); + expect(utf8.decode(viaDecorator), '{"legacy":true}'); + }); + + test('download resolves filename via listFiles for AAD', () async { + await provider.uploadFile(bytesOf('{"m":1}'), 'ssv1.devB.manifest.json'); + // fresh decorator instance = empty name cache; it must list or getFileInfo + final fresh = EncryptingCloudStorageProvider( + inner, + dataKey: dataKey, + libraryKeyId: _keyId, + ); + final files = await fresh.listFiles(namePattern: 'ssv1.'); + final m = files.singleWhere((f) => f.name == 'ssv1.devB.manifest.json'); + final bytes = await fresh.downloadFile(m.id); + expect(utf8.decode(bytes), '{"m":1}'); + }); + + test('download with cold cache falls back to getFileInfo', () async { + final up = await provider.uploadFile( + bytesOf('{"cold":true}'), + 'ssv1.devC.cs.00001.json', + ); + final fresh = EncryptingCloudStorageProvider( + inner, + dataKey: dataKey, + libraryKeyId: _keyId, + ); + // No listFiles call first: must resolve the name via getFileInfo. + final bytes = await fresh.downloadFile(up.fileId); + expect(utf8.decode(bytes), '{"cold":true}'); + }); + + test('delegates providerName/providerId and deleteFile', () async { + expect(provider.providerId, inner.providerId); + expect(provider.providerName, inner.providerName); + final up = await provider.uploadFile(bytesOf('x'), 'ssv1.devA.cs.1.json'); + await provider.deleteFile(up.fileId); + expect(await inner.fileExists(up.fileId), isFalse); + }); + + test('delegates the remaining provider surface untouched', () async { + expect(await provider.isAvailable(), isTrue); + expect(await provider.isAuthenticated(), isTrue); + expect(await provider.getUserEmail(), 'test@example.com'); + // authenticate / signOut are no-ops on the fake; assert they pass through + // without throwing. + await provider.authenticate(); + await provider.signOut(); + expect(await provider.getOrCreateSyncFolder(), 'sync'); + final folder = await provider.createFolder('Sub', parentFolderId: 'root'); + expect(folder, 'root/Sub'); + final up = await provider.uploadFile(bytesOf('y'), 'ssv1.devA.cs.2.json'); + expect(await provider.fileExists(up.fileId), isTrue); + final info = await provider.getFileInfo(up.fileId); + expect(info?.name, 'ssv1.devA.cs.2.json'); + }); + + test( + 'framed backup artifacts pass through download untouched (exempt)', + () async { + // A backup artifact is self-framed: it carries the SBE1 magic but must + // NOT be opened as a single-shot envelope. Upload it via the RAW inner + // provider (the backup service owns its encryption) with the SBE1 magic, + // then download through the decorator and expect the bytes verbatim. + final framed = Uint8List.fromList([ + ...SyncEnvelope.magic, + ...List.filled(64, 7), // opaque framed body; never opened + ]); + final up = await inner.uploadFile( + framed, + 'submersion_backup_2026-07-10.sbe', + ); + // Populate the decorator's name cache the way production does (listFiles). + await provider.listFiles(namePattern: 'submersion_backup_'); + final out = await provider.downloadFile(up.fileId); + expect(out, framed, reason: 'exempt artifact must not be decrypted'); + }, + ); + + test('exemption rules: keyslots and .sbe backups only', () { + expect( + EncryptingCloudStorageProvider.isExempt('submersion_keyslots.json'), + isTrue, + ); + expect( + EncryptingCloudStorageProvider.isExempt('submersion_backup_x.sbe'), + isTrue, + ); + // A plaintext .db backup is NOT exempt by the framed rule (it has no + // magic on download, so it never needs the exemption anyway). + expect( + EncryptingCloudStorageProvider.isExempt('submersion_backup_x.db'), + isFalse, + ); + expect( + EncryptingCloudStorageProvider.isExempt('ssv1.devA.manifest.json'), + isFalse, + ); + }); + + test( + 'download throws when an encrypted file has no resolvable name', + () async { + // A (pathological) provider that returns SBE1 bytes on download but no + // file info and no listing -- the decorator cannot resolve the AAD name + // and must fail closed rather than guess. + final sealed = await SyncEnvelope.seal( + plaintext: bytesOf('{"x":1}'), + dataKey: dataKey, + libraryKeyId: _keyId, + filename: 'ssv1.devA.cs.9.json', + ); + final nameless = _NamelessProvider(sealed); + final wrapped = EncryptingCloudStorageProvider( + nameless, + dataKey: dataKey, + libraryKeyId: _keyId, + ); + await expectLater( + wrapped.downloadFile('whatever'), + throwsA(isA()), + ); + }, + ); +} + +/// Returns SBE1 bytes on download but null file info and no listing, so the +/// decorator has no name to authenticate with. +class _NamelessProvider extends Fake implements CloudStorageProvider { + _NamelessProvider(this._bytes); + final Uint8List _bytes; + + @override + Future downloadFile(String fileId) async => _bytes; + + @override + Future getFileInfo(String fileId) async => null; +} diff --git a/test/core/services/sync/crypto/crypto_errors_test.dart b/test/core/services/sync/crypto/crypto_errors_test.dart new file mode 100644 index 000000000..ab2881f79 --- /dev/null +++ b/test/core/services/sync/crypto/crypto_errors_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/features/backup/domain/exceptions/backup_encrypted_exception.dart'; + +void main() { + group('SyncEncryptionRequired', () { + test('defaults and toString', () { + const e = SyncEncryptionRequired(); + expect(e.libraryKeyId, isNull); + expect(e.message, 'The cloud library is encrypted'); + expect( + e.toString(), + 'SyncEncryptionRequired(null): The cloud library is encrypted', + ); + }); + + test('carries the keyId and a custom message', () { + const e = SyncEncryptionRequired(libraryKeyId: 'abc', message: 'nope'); + expect(e.toString(), 'SyncEncryptionRequired(abc): nope'); + }); + }); + + group('EnvelopeCorruptException', () { + test('toString includes the message', () { + const e = EnvelopeCorruptException('bad tag'); + expect(e.message, 'bad tag'); + expect(e.toString(), 'EnvelopeCorruptException: bad tag'); + }); + }); + + test('BackupEncryptedException toString', () { + expect( + const BackupEncryptedException().toString(), + 'BackupEncryptedException: backup requires a passphrase', + ); + }); +} diff --git a/test/core/services/sync/crypto/encryption_key_store_test.dart b/test/core/services/sync/crypto/encryption_key_store_test.dart new file mode 100644 index 000000000..59a9b1c10 --- /dev/null +++ b/test/core/services/sync/crypto/encryption_key_store_test.dart @@ -0,0 +1,46 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; + +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('saveKey/loadKey/clearKey round-trip', () async { + final store = EncryptionKeyStore(storage: InMemoryKeychain()); + expect(await store.loadKey(), isNull); + final mlk = List.generate(32, (i) => i); + await store.saveKey( + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + mlkBytes: mlk, + ); + final loaded = await store.loadKey(); + expect(loaded!.libraryKeyId, '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'); + expect(await loaded.mlk.extractBytes(), mlk); + await store.clearKey(); + expect(await store.loadKey(), isNull); + }); + + test('keyslot mirror round-trip', () async { + final store = EncryptionKeyStore(storage: InMemoryKeychain()); + expect(await store.loadKeyslotMirror(), isNull); + final bytes = Uint8List.fromList([1, 2, 3, 250]); + await store.saveKeyslotMirror(bytes); + expect(await store.loadKeyslotMirror(), bytes); + await store.clearKeyslotMirror(); + expect(await store.loadKeyslotMirror(), isNull); + }); + + test('SyncPreferences encryption flag defaults false and persists', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = SyncPreferences(await SharedPreferences.getInstance()); + expect(prefs.syncEncryptionEnabled, isFalse); + await prefs.setSyncEncryptionEnabled(true); + expect(prefs.syncEncryptionEnabled, isTrue); + }); +} diff --git a/test/core/services/sync/crypto/keyslots_test.dart b/test/core/services/sync/crypto/keyslots_test.dart new file mode 100644 index 000000000..f7d388c47 --- /dev/null +++ b/test/core/services/sync/crypto/keyslots_test.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; + +Map _vectors() => + jsonDecode( + File('test/fixtures/crypto/crypto_vectors.json').readAsStringSync(), + ) + as Map; + +void main() { + final v = _vectors(); + // Small KDF params keep the suite fast; production defaults differ. + const fastKdf = KdfParams(m: 1024, t: 3, p: 1); + + group('Keyslots', () { + test('Argon2id derivation matches python vector (KAT)', () async { + final c = v['argon2id'] as Map; + final kek = await Keyslots.deriveKek( + secret: c['password'] as String, + salt: base64Decode(c['salt'] as String), + kdf: KdfParams(m: c['m'] as int, t: c['t'] as int, p: c['p'] as int), + ); + expect(await kek.extractBytes(), base64Decode(c['output'] as String)); + }); + + test('HKDF data key matches python vector (KAT)', () async { + final c = v['hkdfData'] as Map; + final dataKey = await Keyslots.deriveDataKey( + SecretKey(base64Decode(c['ikm'] as String)), + ); + expect(await dataKey.extractBytes(), base64Decode(c['output'] as String)); + }); + + test('create slot then unwrap returns the MLK', () async { + final mlk = SecretKey(List.generate(32, (i) => i * 7 % 256)); + final slot = await Keyslots.createSlot( + type: 'passphrase', + secret: 'open sesame 42', + mlk: mlk, + kdf: fastKdf, + ); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [slot], + ); + final unwrapped = await Keyslots.tryUnwrap( + file: file, + secret: 'open sesame 42', + ); + expect(await unwrapped!.extractBytes(), await mlk.extractBytes()); + }); + + test('wrong secret returns null', () async { + final mlk = SecretKey(List.generate(32, (i) => i)); + final slot = await Keyslots.createSlot( + type: 'passphrase', + secret: 'right', + mlk: mlk, + kdf: fastKdf, + ); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [slot], + ); + expect(await Keyslots.tryUnwrap(file: file, secret: 'wrong'), isNull); + }); + + test('keyslot file JSON round-trips and matches spec shape', () async { + final mlk = SecretKey(List.generate(32, (i) => 32 - i)); + final p = await Keyslots.createSlot( + type: 'passphrase', + secret: 'p', + mlk: mlk, + kdf: fastKdf, + ); + final r = await Keyslots.createSlot( + type: 'recovery', + secret: 'acid-acorn-acre-act-add-age-aid-aim', + mlk: mlk, + kdf: fastKdf, + ); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [p, r], + ); + final decoded = KeyslotFile.fromJsonBytes(file.toJsonBytes()); + expect(decoded.version, 1); + expect(decoded.libraryKeyId, file.libraryKeyId); + expect(decoded.slots.length, 2); + expect(decoded.slots.first.kdf.alg, 'argon2id'); + // and the decoded copy still unlocks: + final unwrapped = await Keyslots.tryUnwrap(file: decoded, secret: 'p'); + expect(await unwrapped!.extractBytes(), await mlk.extractBytes()); + }); + + test('withReplacedSlot swaps by type', () async { + final mlk = SecretKey(List.generate(32, (i) => i)); + final p1 = await Keyslots.createSlot( + type: 'passphrase', + secret: 'one', + mlk: mlk, + kdf: fastKdf, + ); + final p2 = await Keyslots.createSlot( + type: 'passphrase', + secret: 'two', + mlk: mlk, + kdf: fastKdf, + ); + final file = KeyslotFile( + version: 1, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + slots: [p1], + ).withReplacedSlot(p2); + expect(file.slots.length, 1); + expect(await Keyslots.tryUnwrap(file: file, secret: 'one'), isNull); + expect(await Keyslots.tryUnwrap(file: file, secret: 'two'), isNotNull); + }); + }); +} diff --git a/test/core/services/sync/crypto/recovery_code_test.dart b/test/core/services/sync/crypto/recovery_code_test.dart new file mode 100644 index 000000000..afac86a7b --- /dev/null +++ b/test/core/services/sync/crypto/recovery_code_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/eff_short_wordlist.dart'; +import 'package:submersion/core/services/sync/crypto/recovery_code.dart'; + +void main() { + group('RecoveryCode', () { + test('wordlist integrity: 1296 unique lowercase words', () { + expect(effShortWordlist.length, 1296); + expect(effShortWordlist.toSet().length, 1296); + for (final w in effShortWordlist) { + // The canonical EFF list includes one hyphenated word (yo-yo). + // Hyphens are safe: normalize() collapses separator runs, so the + // derived secret is identical however the user re-enters it. + expect(w, matches(RegExp(r'^[a-z\-]+$'))); + } + }); + + test('generate returns 8 wordlist words joined by hyphens', () { + final code = RecoveryCode.generate(); + final words = code.split('-'); + expect(words.length, 8); + for (final w in words) { + expect(effShortWordlist.contains(w), isTrue, reason: w); + } + }); + + test('two generated codes differ', () { + expect(RecoveryCode.generate(), isNot(RecoveryCode.generate())); + }); + + test('normalize is tolerant of case, spaces, and separators', () { + expect( + RecoveryCode.normalize(' Acid ACORN\tacre - act\nadd age aid aim '), + 'acid-acorn-acre-act-add-age-aid-aim', + ); + expect( + RecoveryCode.normalize('acid-acorn-acre-act-add-age-aid-aim'), + 'acid-acorn-acre-act-add-age-aid-aim', + ); + }); + }); +} diff --git a/test/core/services/sync/crypto/sync_encryption_service_test.dart b/test/core/services/sync/crypto/sync_encryption_service_test.dart new file mode 100644 index 000000000..72a15dccd --- /dev/null +++ b/test/core/services/sync/crypto/sync_encryption_service_test.dart @@ -0,0 +1,225 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/core/services/sync/library_epoch_store.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; + +import '../../../../support/fake_cloud_storage_provider.dart'; +import '../../../../support/fake_keychain_storage.dart'; + +const _fastKdf = KdfParams(m: 1024, t: 3, p: 1); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeCloudStorageProvider cloud; + late EncryptionKeyStore keyStore; + late SyncPreferences prefs; + late LibraryEpochStore epochStore; + late SyncEncryptionService service; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final sharedPrefs = await SharedPreferences.getInstance(); + cloud = FakeCloudStorageProvider(); + keyStore = EncryptionKeyStore(storage: InMemoryKeychain()); + prefs = SyncPreferences(sharedPrefs); + epochStore = LibraryEpochStore(sharedPrefs); + service = SyncEncryptionService(keyStore: keyStore, preferences: prefs); + }); + + Future enable() => service.enable( + rawProvider: cloud, + passphrase: 'correct horse battery staple', + epochStore: epochStore, + deviceId: 'device-a', + deviceName: 'Test Mac', + kdf: _fastKdf, + ); + + Future cloudKeyslots() async { + final files = await cloud.listFiles(namePattern: KeyslotFile.cloudFileName); + final match = files.singleWhere((f) => f.name == KeyslotFile.cloudFileName); + return KeyslotFile.fromJsonBytes(await cloud.downloadFile(match.id)); + } + + test( + 'enable uploads keyslots, saves key+mirror, flags, pends replace', + () async { + final result = await enable(); + + final file = await cloudKeyslots(); + expect(file.libraryKeyId, result.libraryKeyId); + expect(file.slots.map((s) => s.type), ['passphrase', 'recovery']); + // Both secrets unwrap the same MLK: + final viaPass = await Keyslots.tryUnwrap( + file: file, + secret: 'correct horse battery staple', + ); + final viaCode = await Keyslots.tryUnwrap( + file: file, + secret: result.recoveryCode, + ); + expect(viaPass, isNotNull); + expect(await viaCode!.extractBytes(), await viaPass!.extractBytes()); + // Local state: + final key = await keyStore.loadKey(); + expect(key!.libraryKeyId, result.libraryKeyId); + expect(await keyStore.loadKeyslotMirror(), isNotNull); + expect(prefs.syncEncryptionEnabled, isTrue); + expect(epochStore.pendingReplace, isNotNull); + expect(epochStore.pendingReplace!.deviceId, 'device-a'); + }, + ); + + test('unlock on a second device recovers the same MLK', () async { + final enabled = await enable(); + final keyStoreB = EncryptionKeyStore(storage: InMemoryKeychain()); + final serviceB = SyncEncryptionService( + keyStore: keyStoreB, + preferences: prefs, + ); + final unlocked = await serviceB.unlock( + rawProvider: cloud, + secret: 'correct horse battery staple', + ); + expect(unlocked.libraryKeyId, enabled.libraryKeyId); + final keyA = await keyStore.loadKey(); + final keyB = await keyStoreB.loadKey(); + expect(await keyB!.mlk.extractBytes(), await keyA!.mlk.extractBytes()); + expect(await keyStoreB.loadKeyslotMirror(), isNotNull); + }); + + test('unlock failures: wrong secret and missing keyslot file', () async { + await enable(); + await expectLater( + service.unlock(rawProvider: cloud, secret: 'nope'), + throwsA(isA()), + ); + // Remove the keyslot file entirely: + await service.deleteCloudKeyslots(cloud); + await expectLater( + service.unlock(rawProvider: cloud, secret: 'anything'), + throwsA(isA()), + ); + }); + + test('changePassphrase rewraps: old dead, new works, recovery survives, ' + 'keyId unchanged', () async { + final enabled = await enable(); + await service.changePassphrase( + rawProvider: cloud, + currentSecret: 'correct horse battery staple', + newPassphrase: 'brand new passphrase', + kdf: _fastKdf, + ); + final file = await cloudKeyslots(); + expect(file.libraryKeyId, enabled.libraryKeyId); + expect( + await Keyslots.tryUnwrap( + file: file, + secret: 'correct horse battery staple', + ), + isNull, + ); + expect( + await Keyslots.tryUnwrap(file: file, secret: 'brand new passphrase'), + isNotNull, + ); + expect( + await Keyslots.tryUnwrap(file: file, secret: enabled.recoveryCode), + isNotNull, + ); + }); + + test('regenerateRecoveryCode: old code dead, new code unlocks', () async { + final enabled = await enable(); + final newCode = await service.regenerateRecoveryCode( + rawProvider: cloud, + passphrase: 'correct horse battery staple', + kdf: _fastKdf, + ); + expect(newCode, isNot(enabled.recoveryCode)); + final file = await cloudKeyslots(); + expect( + await Keyslots.tryUnwrap(file: file, secret: enabled.recoveryCode), + isNull, + ); + expect(await Keyslots.tryUnwrap(file: file, secret: newCode), isNotNull); + }); + + test('disable clears flag, pends replace, keeps the stored key', () async { + await enable(); + // Consume the enable replace so disable's pending is unambiguous: + await epochStore.clearPendingReplace(); + + await service.disable(epochStore: epochStore, deviceId: 'device-a'); + expect(prefs.syncEncryptionEnabled, isFalse); + expect(epochStore.pendingReplace, isNotNull); + expect(await keyStore.loadKey(), isNotNull); // kept for old backups + + await service.deleteCloudKeyslots(cloud); + final files = await cloud.listFiles(namePattern: KeyslotFile.cloudFileName); + expect(files.where((f) => f.name == KeyslotFile.cloudFileName), isEmpty); + }); + + test( + 'selfHealKeyslots re-uploads the mirror when the cloud copy vanishes', + () async { + await enable(); + final mirror = await keyStore.loadKeyslotMirror(); + await service.deleteCloudKeyslots(cloud); + + await service.selfHealKeyslots(cloud); + + final files = await cloud.listFiles( + namePattern: KeyslotFile.cloudFileName, + ); + final match = files.singleWhere( + (f) => f.name == KeyslotFile.cloudFileName, + ); + expect(await cloud.downloadFile(match.id), mirror); + }, + ); + + test('selfHealKeyslots is a no-op when the cloud copy exists', () async { + await enable(); + final before = await cloud.listFiles( + namePattern: KeyslotFile.cloudFileName, + ); + await service.selfHealKeyslots(cloud); + final after = await cloud.listFiles(namePattern: KeyslotFile.cloudFileName); + expect(after.length, before.length); + }); + + test('changePassphrase falls back to the local mirror when the cloud ' + 'keyslot file is momentarily missing', () async { + await enable(); + // Simulate an eventually-consistent backend where the just-written + // keyslot file is not yet listable: delete it from the cloud but keep + // the local mirror. changePassphrase must still succeed via the mirror. + await service.deleteCloudKeyslots(cloud); + await service.changePassphrase( + rawProvider: cloud, + currentSecret: 'correct horse battery staple', + newPassphrase: 'freshly rotated pass', + kdf: _fastKdf, + ); + final file = await cloudKeyslots(); + expect( + await Keyslots.tryUnwrap(file: file, secret: 'freshly rotated pass'), + isNotNull, + ); + }); + + test('WrongPassphraseException toString', () { + expect( + const WrongPassphraseException().toString(), + 'WrongPassphraseException', + ); + }); +} diff --git a/test/core/services/sync/crypto/sync_envelope_test.dart b/test/core/services/sync/crypto/sync_envelope_test.dart new file mode 100644 index 000000000..b7dc4af60 --- /dev/null +++ b/test/core/services/sync/crypto/sync_envelope_test.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; + +Map _vectors() => + jsonDecode( + File('test/fixtures/crypto/crypto_vectors.json').readAsStringSync(), + ) + as Map; + +Uint8List _b64(Map m, String k) => + base64Decode(m[k] as String); + +void main() { + final v = _vectors(); + + group('SyncEnvelope', () { + test('opens the python-built plain envelope (KAT)', () async { + final c = v['envelopePlain'] as Map; + final out = await SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + ); + expect(out, _b64(c, 'plaintext')); + }); + + test('opens the python-built gzip envelope (KAT)', () async { + final c = v['envelopeGzip'] as Map; + final out = await SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + ); + expect(out, _b64(c, 'plaintext')); + }); + + test( + 'seal produces byte-exact envelope with injected nonce (KAT)', + () async { + final c = v['envelopePlain'] as Map; + final aes = v['aesGcm'] as Map; + final sealed = await SyncEnvelope.seal( + plaintext: _b64(c, 'plaintext'), + dataKey: SecretKey(_b64(c, 'key')), + libraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + compress: false, + nonceForTest: _b64(aes, 'nonce'), + ); + expect(sealed, _b64(c, 'envelope')); + }, + ); + + test('round-trips with compression and random nonce', () async { + final key = SecretKey(List.generate(32, (i) => 255 - i)); + final plain = Uint8List.fromList( + utf8.encode('{"repeat":"${'ab' * 4000}"}'), + ); + final sealed = await SyncEnvelope.seal( + plaintext: plain, + dataKey: key, + libraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + filename: 'f.json', + ); + expect(sealed.length, lessThan(plain.length)); // gzip effective + final opened = await SyncEnvelope.open( + envelope: sealed, + dataKey: key, + expectedLibraryKeyId: '8f14e45f-ceea-467f-ab37-a10a8d5f4c11', + filename: 'f.json', + ); + expect(opened, plain); + }); + + test('wrong filename (AAD) fails authentication', () async { + final c = v['envelopePlain'] as Map; + await expectLater( + SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: 'ssv1.devB.manifest.json', + ), + throwsA(isA()), + ); + }); + + test('bit flip in ciphertext fails authentication', () async { + final c = v['envelopePlain'] as Map; + final tampered = Uint8List.fromList(_b64(c, 'envelope')); + tampered[tampered.length - 1] ^= 0x01; + await expectLater( + SyncEnvelope.open( + envelope: tampered, + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: c['keyId'] as String, + filename: c['filename'] as String, + ), + throwsA(isA()), + ); + }); + + test( + 'keyId mismatch throws SyncEncryptionRequired with the keyId', + () async { + final c = v['envelopePlain'] as Map; + await expectLater( + SyncEnvelope.open( + envelope: _b64(c, 'envelope'), + dataKey: SecretKey(_b64(c, 'key')), + expectedLibraryKeyId: '00000000-0000-0000-0000-000000000000', + filename: c['filename'] as String, + ), + throwsA( + isA().having( + (e) => e.libraryKeyId, + 'libraryKeyId', + c['keyId'] as String, + ), + ), + ); + }, + ); + + test('hasMagic and libraryKeyIdOf', () { + final c = v['envelopePlain'] as Map; + final env = _b64(c, 'envelope'); + expect(SyncEnvelope.hasMagic(env), isTrue); + expect(SyncEnvelope.hasMagic(utf8.encode('{"json":1}')), isFalse); + expect(SyncEnvelope.libraryKeyIdOf(env), c['keyId'] as String); + expect( + () => SyncEnvelope.libraryKeyIdOf(Uint8List.fromList([1, 2, 3])), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/services/sync/encrypted_sync_integration_test.dart b/test/core/services/sync/encrypted_sync_integration_test.dart new file mode 100644 index 000000000..69571805d --- /dev/null +++ b/test/core/services/sync/encrypted_sync_integration_test.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/cloud_storage/encrypting_cloud_storage_provider.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; +import 'package:submersion/core/services/sync/library_epoch_store.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; +import 'package:submersion/core/services/sync/sync_service.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; + +import '../../../helpers/mock_providers.dart'; +import '../../../helpers/test_database.dart'; +import '../../../support/fake_cloud_storage_provider.dart'; +import '../../../support/fake_keychain_storage.dart'; + +const _fastKdf = KdfParams(m: 1024, t: 3, p: 1); +const _passphrase = 'correct horse battery staple'; + +/// End-to-end encrypted sync: enable-as-replace, the no-plaintext-leak +/// invariant, the locked-device halt, and two-device convergence through +/// the real merge -- all against the raw bytes the provider stores. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'enable -> leak invariant -> locked halt -> unlock -> converge', + () async { + final cloud = FakeCloudStorageProvider(); + + // ---- Device A: seed a dive, enable encryption, publish ---- + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + final prefsA = await SharedPreferences.getInstance(); + final keyStoreA = EncryptionKeyStore(storage: InMemoryKeychain()); + final syncPrefsA = SyncPreferences(prefsA); + final epochStoreA = LibraryEpochStore(prefsA); + final encryptionA = SyncEncryptionService( + keyStore: keyStoreA, + preferences: syncPrefsA, + ); + + await DiveRepository().createDive( + createTestDiveWithBottomTime(id: 'a1', diveNumber: 1), + ); + + await encryptionA.enable( + rawProvider: cloud, + passphrase: _passphrase, + epochStore: epochStoreA, + deviceId: 'device-a', + kdf: _fastKdf, + ); + final keyA = (await keyStoreA.loadKey())!; + final providerA = EncryptingCloudStorageProvider( + cloud, + dataKey: await Keyslots.deriveDataKey(keyA.mlk), + libraryKeyId: keyA.libraryKeyId, + ); + var svcA = SyncService( + syncRepository: SyncRepository(), + serializer: SyncDataSerializer(), + cloudProvider: providerA, + epochStore: epochStoreA, + encryptionService: encryptionA, + ); + // First sync consumes the pending replace (wipes + republishes encrypted); + // the second is a normal encrypted sync. + expect((await svcA.performSync()).status, SyncResultStatus.success); + expect((await svcA.performSync()).status, SyncResultStatus.success); + + // ---- No-plaintext-leak invariant over the RAW stored bytes ---- + final stored = cloud.allStoredFiles(); + expect(stored, isNotEmpty); + for (final f in stored) { + if (f.name == KeyslotFile.cloudFileName) { + expect( + SyncEnvelope.hasMagic(f.bytes), + isFalse, + reason: 'keyslot file must stay plaintext (bootstrap)', + ); + continue; + } + expect( + SyncEnvelope.hasMagic(f.bytes), + isTrue, + reason: '${f.name} stored as plaintext', + ); + expect( + utf8.decode(f.bytes, allowMalformed: true), + isNot(contains('epochId')), + reason: '${f.name} leaks protocol plaintext', + ); + } + await tearDownTestDatabase(); + + // ---- Device B (locked): fresh DB + RAW provider -> halt ---- + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + final prefsB = await SharedPreferences.getInstance(); + final keyStoreB = EncryptionKeyStore(storage: InMemoryKeychain()); + final syncPrefsB = SyncPreferences(prefsB); + final epochStoreB = LibraryEpochStore(prefsB); + final encryptionB = SyncEncryptionService( + keyStore: keyStoreB, + preferences: syncPrefsB, + ); + + var svcB = SyncService( + syncRepository: SyncRepository(), + serializer: SyncDataSerializer(), + cloudProvider: cloud, // raw: no key yet + epochStore: epochStoreB, + ); + expect( + (await svcB.performSync()).status, + SyncResultStatus.awaitingPassphrase, + reason: 'a locked device must halt, not error or destroy anything', + ); + + // ---- Device B unlocks, adopts, converges ---- + final unlockedB = await encryptionB.unlock( + rawProvider: cloud, + secret: _passphrase, + ); + final providerB = EncryptingCloudStorageProvider( + cloud, + dataKey: await Keyslots.deriveDataKey(unlockedB.mlk), + libraryKeyId: unlockedB.libraryKeyId, + ); + svcB = SyncService( + syncRepository: SyncRepository(), + serializer: SyncDataSerializer(), + cloudProvider: providerB, + epochStore: epochStoreB, + encryptionService: encryptionB, + ); + // The encrypted marker now decrypts; the unaccepted epoch halts for + // adoption. B is empty, so adopt (mirroring the notifier's silent path). + final rb = await svcB.performSync(); + expect(rb.status, SyncResultStatus.awaitingAdoption); + expect((await svcB.adoptReplacedLibrary()).isSuccess, isTrue); + expect((await svcB.performSync()).status, SyncResultStatus.success); + + final row = await DatabaseService.instance.database + .customSelect("SELECT id FROM dives WHERE id = 'a1'") + .getSingleOrNull(); + expect( + row, + isNotNull, + reason: "device B must receive device A's dive through encryption", + ); + + // ---- Steady-state: B edits, A pulls the encrypted changeset ---- + await DiveRepository().createDive( + createTestDiveWithBottomTime(id: 'b1', diveNumber: 2), + ); + expect((await svcB.performSync()).status, SyncResultStatus.success); + await tearDownTestDatabase(); + + await setUpTestDatabase(); + // Device A state was torn down with its DB; re-adopt as an empty device + // to prove the changeset path (this also exercises re-join). + SharedPreferences.setMockInitialValues({}); + final prefsA2 = await SharedPreferences.getInstance(); + final keyStoreA2 = EncryptionKeyStore(storage: InMemoryKeychain()); + final encryptionA2 = SyncEncryptionService( + keyStore: keyStoreA2, + preferences: SyncPreferences(prefsA2), + ); + final unlockedA2 = await encryptionA2.unlock( + rawProvider: cloud, + secret: _passphrase, + ); + svcA = SyncService( + syncRepository: SyncRepository(), + serializer: SyncDataSerializer(), + cloudProvider: EncryptingCloudStorageProvider( + cloud, + dataKey: await Keyslots.deriveDataKey(unlockedA2.mlk), + libraryKeyId: unlockedA2.libraryKeyId, + ), + epochStore: LibraryEpochStore(prefsA2), + ); + final ra2 = await svcA.performSync(); + expect(ra2.status, SyncResultStatus.awaitingAdoption); + expect((await svcA.adoptReplacedLibrary()).isSuccess, isTrue); + expect((await svcA.performSync()).status, SyncResultStatus.success); + + final both = await DatabaseService.instance.database + .customSelect('SELECT COUNT(*) AS c FROM dives') + .getSingle(); + expect(both.data['c'], 2, reason: 'a1 and b1 must both converge'); + + // The leak invariant still holds after all the above traffic. + for (final f in cloud.allStoredFiles()) { + if (f.name == KeyslotFile.cloudFileName) continue; + expect( + SyncEnvelope.hasMagic(f.bytes), + isTrue, + reason: '${f.name} stored as plaintext after steady-state syncs', + ); + } + await tearDownTestDatabase(); + }, + ); +} diff --git a/test/core/services/sync/sync_encryption_gate_test.dart b/test/core/services/sync/sync_encryption_gate_test.dart new file mode 100644 index 000000000..aa20fbf9a --- /dev/null +++ b/test/core/services/sync/sync_encryption_gate_test.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/changeset_log/sync_manifest.dart'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; +import 'package:submersion/core/services/sync/library_epoch.dart'; +import 'package:submersion/core/services/sync/library_epoch_store.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; +import 'package:submersion/core/services/sync/sync_service.dart'; + +import '../../../helpers/fake_cloud_storage_provider.dart'; +import '../../../helpers/test_database.dart'; + +/// The encrypted-library gate: what happens when sync meets SBE1 envelopes +/// without a key. Also pins the legacy fail-closed property (spec section 6). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeCloudStorageProvider cloud; + late LibraryEpochStore epochStore; + + final dataKey = SecretKey(List.generate(32, (i) => i)); + const keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + epochStore = LibraryEpochStore(await SharedPreferences.getInstance()); + cloud = FakeCloudStorageProvider(); + }); + + tearDown(() => DatabaseService.instance.resetForTesting()); + + SyncService buildService() => SyncService( + syncRepository: SyncRepository(), + serializer: SyncDataSerializer(), + cloudProvider: cloud, + epochStore: epochStore, + ); + + Future sealedMarker() => SyncEnvelope.seal( + plaintext: Uint8List.fromList( + utf8.encode('{"epochId":"e1","replacedAt":1,"deviceId":"d1"}'), + ), + dataKey: dataKey, + libraryKeyId: keyId, + filename: libraryEpochFileName, + ); + + test('SBE1 bytes can never satisfy the legacy epoch-marker parse', () async { + final envelope = await sealedMarker(); + // This IS the legacy client's exact parse sequence (fail-closed path): + expect( + () => LibraryEpochMarker.fromJson( + jsonDecode(utf8.decode(envelope)) as Map, + ), + throwsA(anything), // utf8/json/FormatException; any throw = fail closed + ); + }); + + test('performSync halts awaitingPassphrase on an encrypted marker', () async { + await cloud.uploadFile(await sealedMarker(), libraryEpochFileName); + final service = buildService(); + final result = await service.performSync(); + expect(result.status, SyncResultStatus.awaitingPassphrase); + }); + + test('readLibraryEpochMarker throws SyncEncryptionRequired on SBE1 ' + 'with the keyId attached', () async { + await cloud.uploadFile(await sealedMarker(), libraryEpochFileName); + final service = buildService(); + await expectLater( + service.readLibraryEpochMarker(cloud), + throwsA( + isA().having( + (e) => e.libraryKeyId, + 'libraryKeyId', + keyId, + ), + ), + ); + }); + + test( + 'SyncManifest.fromBytes throws SyncEncryptionRequired on SBE1', + () async { + final envelope = await SyncEnvelope.seal( + plaintext: Uint8List.fromList(utf8.encode('{}')), + dataKey: dataKey, + libraryKeyId: keyId, + filename: 'ssv1.devA.manifest.json', + ); + expect( + () => SyncManifest.fromBytes(envelope), + throwsA(isA()), + ); + }, + ); +} diff --git a/test/features/backup/data/services/backup_crypto_test.dart b/test/features/backup/data/services/backup_crypto_test.dart new file mode 100644 index 000000000..802ff790c --- /dev/null +++ b/test/features/backup/data/services/backup_crypto_test.dart @@ -0,0 +1,243 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/features/backup/data/services/backup_crypto.dart'; + +const _fastKdf = KdfParams(m: 1024, t: 3, p: 1); +const _keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; +const _passphrase = 'correct horse battery staple'; +const _recoveryCode = 'acid-acorn-acre-act-add-age-aid-aim'; + +void main() { + late Directory tempDir; + late SecretKey mlk; + late Uint8List keyslotBytes; + + setUpAll(() async { + mlk = SecretKey(List.generate(32, (i) => (i * 13 + 5) % 256)); + final file = KeyslotFile( + version: 1, + libraryKeyId: _keyId, + slots: [ + await Keyslots.createSlot( + type: 'passphrase', + secret: _passphrase, + mlk: mlk, + kdf: _fastKdf, + ), + await Keyslots.createSlot( + type: 'recovery', + secret: _recoveryCode, + mlk: mlk, + kdf: _fastKdf, + ), + ], + ); + keyslotBytes = file.toJsonBytes(); + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('backup_crypto_'); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + /// Deterministic pseudo-random content, written in bounded chunks. + Future writeInput(int size, {String name = 'in.db'}) async { + final path = '${tempDir.path}/$name'; + final raf = File(path).openSync(mode: FileMode.write); + const chunkSize = 1 << 20; + var written = 0; + while (written < size) { + final n = (size - written).clamp(0, chunkSize); + raf.writeFromSync( + Uint8List.fromList( + List.generate(n, (i) => ((written + i) * 31 + 7) % 256), + ), + ); + written += n; + } + raf.closeSync(); + return path; + } + + Future encrypt(String inPath, {String name = 'out.sbe'}) async { + final outPath = '${tempDir.path}/$name'; + await BackupCrypto.encryptFile( + inPath: inPath, + outPath: outPath, + mlk: mlk, + libraryKeyId: _keyId, + keyslotBytes: keyslotBytes, + ); + return outPath; + } + + Future filesIdentical(String a, String b) async { + final ba = await File(a).readAsBytes(); + final bb = await File(b).readAsBytes(); + if (ba.length != bb.length) return false; + for (var i = 0; i < ba.length; i++) { + if (ba[i] != bb[i]) return false; + } + return true; + } + + test('20 MiB round-trip with the passphrase (3 frames)', () async { + final input = await writeInput(20 * 1024 * 1024); + final sbe = await encrypt(input); + final out = '${tempDir.path}/out.db'; + await BackupCrypto.decryptFile( + inPath: sbe, + outPath: out, + secret: _passphrase, + ); + expect(await filesIdentical(input, out), isTrue); + }); + + test( + 'decryptFileWithKey: silent with matching key, throws on mismatch', + () async { + final input = await writeInput(1024 * 1024); + final sbe = await encrypt(input); + final out = '${tempDir.path}/out.db'; + await BackupCrypto.decryptFileWithKey( + inPath: sbe, + outPath: out, + mlk: mlk, + expectedLibraryKeyId: _keyId, + ); + expect(await filesIdentical(input, out), isTrue); + + await expectLater( + BackupCrypto.decryptFileWithKey( + inPath: sbe, + outPath: '${tempDir.path}/out2.db', + mlk: mlk, + expectedLibraryKeyId: '00000000-0000-0000-0000-000000000000', + ), + throwsA(isA()), + ); + }, + ); + + test('wrong secret throws WrongPassphraseException', () async { + final input = await writeInput(1024); + final sbe = await encrypt(input); + await expectLater( + BackupCrypto.decryptFile( + inPath: sbe, + outPath: '${tempDir.path}/out.db', + secret: 'totally wrong', + ), + throwsA(isA()), + ); + }); + + test('recovery code decrypts, tolerant of spacing and case', () async { + final input = await writeInput(1024); + final sbe = await encrypt(input); + final out = '${tempDir.path}/out.db'; + await BackupCrypto.decryptFile( + inPath: sbe, + outPath: out, + secret: ' ACID acorn Acre act ADD age aid aim ', + ); + expect(await filesIdentical(input, out), isTrue); + }); + + test('bit flip inside a frame fails authentication', () async { + final input = await writeInput(1024 * 1024); + final sbe = await encrypt(input); + final bytes = await File(sbe).readAsBytes(); + bytes[bytes.length - 20] ^= 0x01; // inside the final frame's tail + await File(sbe).writeAsBytes(bytes); + await expectLater( + BackupCrypto.decryptFile( + inPath: sbe, + outPath: '${tempDir.path}/out.db', + secret: _passphrase, + ), + throwsA(isA()), + ); + }); + + test('truncation before the final frame is detected', () async { + final input = await writeInput(9 * 1024 * 1024); // 2 frames: 8 MiB + 1 MiB + final sbe = await encrypt(input); + final bytes = await File(sbe).readAsBytes(); + // Parse to the end of frame 0 and cut there (drops the final frame). + final slotLen = ByteData.sublistView(bytes, 21, 25).getUint32(0); + final frame0Start = 25 + slotLen; + final frame0Len = ByteData.sublistView( + bytes, + frame0Start, + frame0Start + 4, + ).getUint32(0); + final cut = frame0Start + 4 + frame0Len; + await File(sbe).writeAsBytes(bytes.sublist(0, cut)); + await expectLater( + BackupCrypto.decryptFile( + inPath: sbe, + outPath: '${tempDir.path}/out.db', + secret: _passphrase, + ), + throwsA(isA()), + ); + }); + + test('frame reorder is detected (frame index in AAD)', () async { + final input = await writeInput(17 * 1024 * 1024); // 3 frames + final sbe = await encrypt(input); + final bytes = await File(sbe).readAsBytes(); + final slotLen = ByteData.sublistView(bytes, 21, 25).getUint32(0); + final f0 = 25 + slotLen; + final f0Len = ByteData.sublistView(bytes, f0, f0 + 4).getUint32(0); + final f1 = f0 + 4 + f0Len; + final f1Len = ByteData.sublistView(bytes, f1, f1 + 4).getUint32(0); + final f2 = f1 + 4 + f1Len; + // Swap frame 0 and frame 1 (both are full 8 MiB frames, equal length). + final swapped = BytesBuilder(copy: false) + ..add(bytes.sublist(0, f0)) + ..add(bytes.sublist(f1, f2)) + ..add(bytes.sublist(f0, f1)) + ..add(bytes.sublist(f2)); + await File(sbe).writeAsBytes(swapped.takeBytes()); + await expectLater( + BackupCrypto.decryptFile( + inPath: sbe, + outPath: '${tempDir.path}/out.db', + secret: _passphrase, + ), + throwsA(isA()), + ); + }); + + test('empty input round-trips', () async { + final input = await writeInput(0); + final sbe = await encrypt(input); + final out = '${tempDir.path}/out.db'; + await BackupCrypto.decryptFile( + inPath: sbe, + outPath: out, + secret: _passphrase, + ); + expect(await File(out).length(), 0); + }); + + test('isEncryptedBackup and libraryKeyIdOf', () async { + final input = await writeInput(1024); + final sbe = await encrypt(input); + expect(await BackupCrypto.isEncryptedBackup(sbe), isTrue); + expect(await BackupCrypto.isEncryptedBackup(input), isFalse); + expect(await BackupCrypto.libraryKeyIdOf(sbe), _keyId); + }); +} diff --git a/test/features/backup/data/services/backup_service_encryption_test.dart b/test/features/backup/data/services/backup_service_encryption_test.dart new file mode 100644 index 000000000..a8db7179f --- /dev/null +++ b/test/features/backup/data/services/backup_service_encryption_test.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_envelope.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; +import 'package:submersion/features/backup/data/repositories/backup_preferences.dart'; +import 'package:submersion/features/backup/data/services/backup_service.dart'; +import 'package:submersion/features/backup/domain/exceptions/backup_encrypted_exception.dart'; + +import '../../../../support/fake_cloud_storage_provider.dart'; +import '../../../../support/fake_keychain_storage.dart'; + +const _fastKdf = KdfParams(m: 1024, t: 3, p: 1); +const _passphrase = 'correct horse battery staple'; + +class _FakeBackupDatabaseAdapter implements BackupDatabaseAdapter { + @override + Future backup(String destinationPath) async { + final file = File(destinationPath); + await file.parent.create(recursive: true); + await file.writeAsString('fake backup data'); + } + + @override + Future restore(String backupPath) async {} + + @override + Future get databasePath async => '/fake/db/path'; + + @override + AppDatabase get database => + throw UnimplementedError('Fake database does not support queries'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (MethodCall methodCall) async { + if (methodCall.method == 'getTemporaryDirectory') { + return Directory.systemTemp.path; + } + if (methodCall.method == 'getApplicationDocumentsDirectory') { + return Directory.systemTemp.path; + } + return null; + }, + ); + }); + + late BackupPreferences preferences; + late SyncPreferences syncPreferences; + late EncryptionKeyStore keyStore; + late FakeCloudStorageProvider cloud; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + preferences = BackupPreferences(prefs); + syncPreferences = SyncPreferences(prefs); + keyStore = EncryptionKeyStore(storage: InMemoryKeychain()); + cloud = FakeCloudStorageProvider(); + }); + + BackupService buildService() => BackupService( + dbAdapter: _FakeBackupDatabaseAdapter(), + preferences: preferences, + cloudProvider: cloud, + encryptionKeyStore: keyStore, + syncPreferences: syncPreferences, + ); + + /// Seed an unlocked encrypted-library state (key + mirror + flag). + Future seedEncryption() async { + final mlk = SecretKey(List.generate(32, (i) => (i * 3 + 1) % 256)); + const keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; + final file = KeyslotFile( + version: 1, + libraryKeyId: keyId, + slots: [ + await Keyslots.createSlot( + type: 'passphrase', + secret: _passphrase, + mlk: mlk, + kdf: _fastKdf, + ), + ], + ); + await keyStore.saveKey( + libraryKeyId: keyId, + mlkBytes: await mlk.extractBytes(), + ); + await keyStore.saveKeyslotMirror(file.toJsonBytes()); + await syncPreferences.setSyncEncryptionEnabled(true); + return keyId; + } + + // The support fake's createFolder returns the folder name as its id. + Future backupFolderId() => cloud.createFolder('Submersion Backups'); + + Future<({String name, Uint8List bytes})> singleCloudBackup() async { + final files = await cloud.listFiles( + folderId: await backupFolderId(), + namePattern: 'submersion_backup_', + ); + expect(files, hasLength(1)); + return ( + name: files.single.name, + bytes: await cloud.downloadFile(files.single.id), + ); + } + + test('encryption on: cloud copy is .sbe with SBE1 magic, local stays ' + 'plaintext', () async { + await seedEncryption(); + await preferences.setCloudBackupEnabled(true); + + final service = buildService(); + final record = await service.performBackup(); + + final uploaded = await singleCloudBackup(); + expect(uploaded.name, endsWith('.sbe')); + expect(SyncEnvelope.hasMagic(uploaded.bytes), isTrue); + // Local artifact untouched: + expect(record.localPath, isNotNull); + expect(await File(record.localPath!).readAsString(), 'fake backup data'); + }); + + test('encryption off: cloud copy keeps the plaintext .db name', () async { + await preferences.setCloudBackupEnabled(true); + + final service = buildService(); + await service.performBackup(); + + final uploaded = await singleCloudBackup(); + expect(uploaded.name, endsWith('.db')); + expect(SyncEnvelope.hasMagic(uploaded.bytes), isFalse); + expect(utf8.decode(uploaded.bytes), 'fake backup data'); + }); + + test('restoreFromFile: encrypted artifact with no secret and no cached ' + 'key throws BackupEncryptedException', () async { + final keyId = await seedEncryption(); + await preferences.setCloudBackupEnabled(true); + final service = buildService(); + await service.performBackup(); + final uploaded = await singleCloudBackup(); + // Write the encrypted artifact to disk as a picked file: + final picked = File('${Directory.systemTemp.path}/picked_$keyId.sbe'); + await picked.writeAsBytes(uploaded.bytes); + addTearDown(() async { + if (await picked.exists()) await picked.delete(); + }); + // Drop the cached key: a fresh device with no key and no secret. + await keyStore.clearKey(); + + await expectLater( + service.restoreFromFile(picked.path), + throwsA(isA()), + ); + }); + + test('restoreFromFile: succeeds with the passphrase, and silently with ' + 'the cached key', () async { + await seedEncryption(); + await preferences.setCloudBackupEnabled(true); + final service = buildService(); + await service.performBackup(); + final uploaded = await singleCloudBackup(); + final picked = File('${Directory.systemTemp.path}/picked_enc.sbe'); + await picked.writeAsBytes(uploaded.bytes); + addTearDown(() async { + if (await picked.exists()) await picked.delete(); + }); + + // Cached key path (key still present): no secret needed. + await service.restoreFromFile(picked.path); + + // Passphrase path (no cached key): + await keyStore.clearKey(); + await service.restoreFromFile(picked.path, encryptionSecret: _passphrase); + }); + + test('validateBackupFile accepts an encrypted .sbe artifact', () async { + await seedEncryption(); + await preferences.setCloudBackupEnabled(true); + final service = buildService(); + await service.performBackup(); + final uploaded = await singleCloudBackup(); + final picked = File('${Directory.systemTemp.path}/valid_enc.sbe'); + await picked.writeAsBytes(uploaded.bytes); + addTearDown(() async { + if (await picked.exists()) await picked.delete(); + }); + + final result = await service.validateBackupFile(picked.path); + expect(result.isValid, isTrue); + }); + + test('validateBackupFile rejects a .sbe file that lacks the magic', () async { + final service = buildService(); + final bogus = File('${Directory.systemTemp.path}/bogus.sbe'); + await bogus.writeAsString('not an encrypted backup at all'); + addTearDown(() async { + if (await bogus.exists()) await bogus.delete(); + }); + + final result = await service.validateBackupFile(bogus.path); + expect(result.isValid, isFalse); + }); + + test('deletePlaintextCloudBackups removes only *.db artifacts', () async { + await preferences.setCloudBackupEnabled(true); + final service = buildService(); + await service.performBackup(); // plaintext .db upload + + await seedEncryption(); + await service.performBackup(); // encrypted .sbe upload + + final removed = await service.deletePlaintextCloudBackups(); + expect(removed, 1); + final remaining = await cloud.listFiles( + folderId: await backupFolderId(), + namePattern: 'submersion_backup_', + ); + expect(remaining, hasLength(1)); + expect(remaining.single.name, endsWith('.sbe')); + }); +} diff --git a/test/features/backup/presentation/pages/backup_settings_page_test.dart b/test/features/backup/presentation/pages/backup_settings_page_test.dart index 21a4112f0..b88ccd6d2 100644 --- a/test/features/backup/presentation/pages/backup_settings_page_test.dart +++ b/test/features/backup/presentation/pages/backup_settings_page_test.dart @@ -609,6 +609,7 @@ class _RecordingRestoreService extends BackupService { Future restoreFromBackup( BackupRecord record, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { calls.add('restoreFromBackup'); lastMode = mode; @@ -618,6 +619,7 @@ class _RecordingRestoreService extends BackupService { Future restoreFromFile( String filePath, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { calls.add('restoreFromFile'); lastMode = mode; diff --git a/test/features/backup/presentation/providers/backup_providers_restore_test.dart b/test/features/backup/presentation/providers/backup_providers_restore_test.dart index 387539f93..5a2ed8e3b 100644 --- a/test/features/backup/presentation/providers/backup_providers_restore_test.dart +++ b/test/features/backup/presentation/providers/backup_providers_restore_test.dart @@ -9,6 +9,9 @@ import 'package:submersion/features/backup/data/repositories/backup_preferences. import 'package:submersion/features/backup/data/services/backup_service.dart'; import 'package:submersion/features/backup/domain/entities/backup_record.dart'; import 'package:submersion/features/backup/domain/entities/restore_mode.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart' + show WrongPassphraseException; +import 'package:submersion/features/backup/domain/exceptions/backup_encrypted_exception.dart'; import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -35,10 +38,28 @@ class _NoopAdapter implements BackupDatabaseAdapter { class _RecordingBackupService extends BackupService { final List calls = []; RestoreMode? lastMode; + String? lastSecret; + + /// When true, restore throws [BackupEncryptedException] UNLESS a secret is + /// supplied -- modelling the "encrypted artifact needs a passphrase" path. + bool requireSecret = false; + + /// The only secret that unlocks; any other non-null secret throws + /// [WrongPassphraseException], modelling a wrong passphrase on retry. + String? correctSecret; _RecordingBackupService(BackupPreferences prefs) : super(dbAdapter: _NoopAdapter(), preferences: prefs); + void _gate(String? secret) { + if (requireSecret && secret == null) { + throw const BackupEncryptedException(); + } + if (correctSecret != null && secret != null && secret != correctSecret) { + throw const WrongPassphraseException(); + } + } + @override Future validateBackupFile(String filePath) async => const BackupValidationResult.valid(sizeBytes: 1); @@ -47,18 +68,24 @@ class _RecordingBackupService extends BackupService { Future restoreFromBackup( BackupRecord record, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { calls.add('restoreFromBackup'); lastMode = mode; + lastSecret = encryptionSecret; + _gate(encryptionSecret); } @override Future restoreFromFile( String filePath, { RestoreMode mode = RestoreMode.merge, + String? encryptionSecret, }) async { calls.add('restoreFromFile'); lastMode = mode; + lastSecret = encryptionSecret; + _gate(encryptionSecret); } } @@ -152,4 +179,132 @@ void main() { final built = container.read(backupServiceProvider); expect(built, isA()); }); + + test('restoreFromFilePath rethrows BackupEncryptedException and resets ' + 'to idle so the page can prompt', () async { + service.requireSecret = true; + final container = makeContainer(); + final tmp = File( + '${Directory.systemTemp.path}/notifier_enc_' + '${DateTime.now().microsecondsSinceEpoch}.db', + ); + await tmp.writeAsString('db'); + addTearDown(() async { + if (await tmp.exists()) await tmp.delete(); + }); + + await expectLater( + container + .read(backupOperationProvider.notifier) + .restoreFromFilePath(tmp.path), + throwsA(isA()), + ); + expect( + container.read(backupOperationProvider).status, + BackupOperationStatus.idle, + ); + + // Retrying with a secret succeeds and threads it to the service. + await container + .read(backupOperationProvider.notifier) + .restoreFromFilePath(tmp.path, encryptionSecret: 'pw'); + expect(service.lastSecret, 'pw'); + expect( + container.read(backupOperationProvider).status, + BackupOperationStatus.restoreComplete, + ); + }); + + test('restoreFromFilePath rethrows WrongPassphraseException and resets to ' + 'idle so the passphrase dialog can show the inline error', () async { + service + ..requireSecret = true + ..correctSecret = 'right'; + final container = makeContainer(); + final tmp = File( + '${Directory.systemTemp.path}/notifier_wrongpw_' + '${DateTime.now().microsecondsSinceEpoch}.db', + ); + await tmp.writeAsString('db'); + addTearDown(() async { + if (await tmp.exists()) await tmp.delete(); + }); + + // A wrong secret must propagate (not become an error state), so the + // dialog stays open with its inline error instead of closing on success. + await expectLater( + container + .read(backupOperationProvider.notifier) + .restoreFromFilePath(tmp.path, encryptionSecret: 'wrong'), + throwsA(isA()), + ); + expect( + container.read(backupOperationProvider).status, + BackupOperationStatus.idle, + ); + + // The correct secret then completes the restore. + await container + .read(backupOperationProvider.notifier) + .restoreFromFilePath(tmp.path, encryptionSecret: 'right'); + expect( + container.read(backupOperationProvider).status, + BackupOperationStatus.restoreComplete, + ); + }); + + test('restoreFromBackup rethrows WrongPassphraseException and resets to ' + 'idle', () async { + service + ..requireSecret = true + ..correctSecret = 'right'; + final container = makeContainer(); + final record = BackupRecord( + id: 'enc2', + filename: 'b.sbe', + timestamp: DateTime(2026), + sizeBytes: 1, + location: BackupLocation.local, + ); + + await expectLater( + container + .read(backupOperationProvider.notifier) + .restoreFromBackup(record, encryptionSecret: 'wrong'), + throwsA(isA()), + ); + expect( + container.read(backupOperationProvider).status, + BackupOperationStatus.idle, + ); + }); + + test('restoreFromBackup rethrows BackupEncryptedException and resets ' + 'to idle', () async { + service.requireSecret = true; + final container = makeContainer(); + final record = BackupRecord( + id: 'enc', + filename: 'b.sbe', + timestamp: DateTime(2026), + sizeBytes: 1, + location: BackupLocation.local, + ); + + await expectLater( + container + .read(backupOperationProvider.notifier) + .restoreFromBackup(record), + throwsA(isA()), + ); + expect( + container.read(backupOperationProvider).status, + BackupOperationStatus.idle, + ); + + await container + .read(backupOperationProvider.notifier) + .restoreFromBackup(record, encryptionSecret: 'pw'); + expect(service.lastSecret, 'pw'); + }); } diff --git a/test/features/settings/presentation/pages/cloud_sync_page_test.dart b/test/features/settings/presentation/pages/cloud_sync_page_test.dart index 4b7c2bc03..682cc4b13 100644 --- a/test/features/settings/presentation/pages/cloud_sync_page_test.dart +++ b/test/features/settings/presentation/pages/cloud_sync_page_test.dart @@ -1904,4 +1904,32 @@ void main() { expect(svc.signOutCalled, isTrue); }); }); + + group('encryption unlock banner', () { + testWidgets('needsPassphrase renders the banner with the unlock action', ( + tester, + ) async { + await pumpPage( + tester, + syncState: const SyncState(needsPassphrase: true), + selectedProvider: CloudProviderType.s3, + ); + + expect( + find.text('Enter the passphrase to sync on this device'), + findsOneWidget, + ); + expect(find.text('Enter passphrase'), findsOneWidget); + }); + + testWidgets('no banner without needsPassphrase', (tester) async { + await pumpPage( + tester, + syncState: const SyncState(), + selectedProvider: CloudProviderType.s3, + ); + + expect(find.text('Enter passphrase'), findsNothing); + }); + }); } diff --git a/test/features/settings/presentation/pages/troubleshoot_sync_page_test.dart b/test/features/settings/presentation/pages/troubleshoot_sync_page_test.dart index 742514cb8..43b91cb0e 100644 --- a/test/features/settings/presentation/pages/troubleshoot_sync_page_test.dart +++ b/test/features/settings/presentation/pages/troubleshoot_sync_page_test.dart @@ -1,10 +1,27 @@ +import 'package:cryptography/cryptography.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/providers/provider.dart' show StateNotifier; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/features/settings/presentation/pages/troubleshoot_sync_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +/// A key notifier pre-seeded with an unlocked session, so the page renders the +/// "Encryption is on" state without running real crypto. +class _SeededKeyNotifier extends EncryptionKeyNotifier { + _SeededKeyNotifier( + super.keyStore, + super.preferences, + EncryptionSessionState seeded, + ) { + state = seeded; + } +} + /// Records the recovery calls the Troubleshoot page routes to the notifier so /// the tap-through tests can assert each confirm flow fires exactly once. All /// other SyncNotifier members fall through to noSuchMethod (unused here). @@ -44,48 +61,157 @@ class _FakeSyncNotifier extends StateNotifier } /// Pumps the page with [fake] backing syncStateProvider. Returns the fake. +/// SharedPreferences backs the encryption-status row (preference flag read). Future<_FakeSyncNotifier> _pump(WidgetTester tester) async { final fake = _FakeSyncNotifier(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); await tester.pumpWidget( ProviderScope( - overrides: [syncStateProvider.overrideWith((ref) => fake)], - child: const MaterialApp(home: TroubleshootSyncPage()), + overrides: [ + syncStateProvider.overrideWith((ref) => fake), + sharedPreferencesProvider.overrideWithValue(prefs), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TroubleshootSyncPage(), + ), ), ); await tester.pumpAndSettle(); return fake; } +/// Pump without a fake notifier, for display-only assertions. Preferences +/// still need backing (the encryption-status row reads the flag). +Future _pumpBare(WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TroubleshootSyncPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} + void main() { testWidgets('shows Repair Sync action with an explanation', (tester) async { - await tester.pumpWidget( - const ProviderScope(child: MaterialApp(home: TroubleshootSyncPage())), - ); - await tester.pumpAndSettle(); + await _pumpBare(tester); expect(find.text('Repair Sync'), findsOneWidget); // The explanation must reassure the user their dive data is safe. expect(find.textContaining('dive data'), findsWidgets); }); - testWidgets('shows both cloud-clear actions', (tester) async { + testWidgets('encryption status row shows Off by default', (tester) async { + await _pumpBare(tester); + expect(find.text('End-to-end encryption'), findsOneWidget); + expect(find.text('Encryption is off'), findsOneWidget); + }); + + testWidgets('encryption status row shows the locked state when enabled ' + 'without a session', (tester) async { + SharedPreferences.setMockInitialValues({'sync_encryption_enabled': true}); + final prefs = await SharedPreferences.getInstance(); await tester.pumpWidget( - const ProviderScope(child: MaterialApp(home: TroubleshootSyncPage())), + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TroubleshootSyncPage(), + ), + ), ); await tester.pumpAndSettle(); + expect( + find.text('Enter the passphrase to sync on this device'), + findsOneWidget, + ); + }); - expect(find.text('Remove this device’s cloud files'), findsOneWidget); - expect(find.text('Wipe all sync data on this backend'), findsOneWidget); + testWidgets('encryption status row shows On when enabled and unlocked', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({'sync_encryption_enabled': true}); + final prefs = await SharedPreferences.getInstance(); + final session = EncryptionSessionState( + key: UnlockedKey( + libraryKeyId: 'k', + mlk: SecretKey(List.filled(32, 1)), + ), + dataKey: SecretKey(List.filled(32, 2)), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + encryptionKeyNotifierProvider.overrideWith( + (ref) => _SeededKeyNotifier( + ref.watch(encryptionKeyStoreProvider), + ref.watch(syncPreferencesProvider), + session, + ), + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TroubleshootSyncPage(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Encryption is on'), findsOneWidget); }); - testWidgets('shows the rebuild-from-this-device action and confirm dialog', ( + testWidgets('tapping the locked encryption row runs the unlock flow', ( tester, ) async { + SharedPreferences.setMockInitialValues({'sync_encryption_enabled': true}); + final prefs = await SharedPreferences.getInstance(); await tester.pumpWidget( - const ProviderScope(child: MaterialApp(home: TroubleshootSyncPage())), + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + // No provider configured: the unlock flow bails without a dialog, + // but the row's onTap closure still executes. + cloudStorageProviderProvider.overrideWithValue(null), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TroubleshootSyncPage(), + ), + ), ); await tester.pumpAndSettle(); + await tester.tap(find.text('End-to-end encryption')); + await tester.pumpAndSettle(); + // With no cloud provider the unlock flow returns without opening a dialog. + expect(find.widgetWithText(FilledButton, 'Unlock'), findsNothing); + }); + + testWidgets('shows both cloud-clear actions', (tester) async { + await _pumpBare(tester); + + expect(find.text('Remove this device’s cloud files'), findsOneWidget); + expect(find.text('Wipe all sync data on this backend'), findsOneWidget); + }); + + testWidgets('shows the rebuild-from-this-device action and confirm dialog', ( + tester, + ) async { + await _pumpBare(tester); + expect(find.text('Rebuild backend from this device'), findsOneWidget); await tester.tap(find.text('Rebuild backend from this device')); @@ -97,10 +223,7 @@ void main() { }); testWidgets('wipe-all requires typed confirmation', (tester) async { - await tester.pumpWidget( - const ProviderScope(child: MaterialApp(home: TroubleshootSyncPage())), - ); - await tester.pumpAndSettle(); + await _pumpBare(tester); await tester.tap(find.text('Wipe all sync data on this backend')); await tester.pumpAndSettle(); diff --git a/test/features/settings/presentation/providers/sync_encryption_providers_test.dart b/test/features/settings/presentation/providers/sync_encryption_providers_test.dart new file mode 100644 index 000000000..b8758189d --- /dev/null +++ b/test/features/settings/presentation/providers/sync_encryption_providers_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/cloud_storage/encrypting_cloud_storage_provider.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; + +import '../../../../support/fake_keychain_storage.dart'; + +/// The riverpod seam of encrypted sync: the provider wrap follows the +/// unlocked SESSION, and the session follows the preference flag. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; + final mlkBytes = List.generate(32, (i) => i); + + late EncryptionKeyStore keyStore; + + Future makeContainer({ + required bool encryptionEnabled, + }) async { + SharedPreferences.setMockInitialValues({ + 'sync_encryption_enabled': encryptionEnabled, + }); + final prefs = await SharedPreferences.getInstance(); + keyStore = EncryptionKeyStore(storage: InMemoryKeychain()); + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + encryptionKeyStoreProvider.overrideWithValue(keyStore), + ], + ); + addTearDown(container.dispose); + // Select a provider type so cloudStorageProviderProvider is non-null. + container.read(selectedCloudProviderTypeProvider.notifier).state = + CloudProviderType.s3; + return container; + } + + test('disabled: resolved provider is the raw provider', () async { + final container = await makeContainer(encryptionEnabled: false); + final provider = container.read(cloudStorageProviderProvider); + expect(provider, isNotNull); + expect(provider, isNot(isA())); + }); + + test('enabled + unlocked session: resolved provider encrypts', () async { + final container = await makeContainer(encryptionEnabled: true); + await keyStore.saveKey(libraryKeyId: keyId, mlkBytes: mlkBytes); + final loaded = await container + .read(encryptionKeyNotifierProvider.notifier) + .ensureLoaded(); + expect(loaded, isNotNull); + final provider = container.read(cloudStorageProviderProvider); + expect(provider, isA()); + }); + + test('enabled but no stored key: session stays null, provider raw', () async { + final container = await makeContainer(encryptionEnabled: true); + final loaded = await container + .read(encryptionKeyNotifierProvider.notifier) + .ensureLoaded(); + expect(loaded, isNull); + final provider = container.read(cloudStorageProviderProvider); + expect(provider, isNot(isA())); + }); + + test( + 'flag OFF with a stored key: no session (key kept for backups only)', + () async { + final container = await makeContainer(encryptionEnabled: false); + await keyStore.saveKey(libraryKeyId: keyId, mlkBytes: mlkBytes); + final loaded = await container + .read(encryptionKeyNotifierProvider.notifier) + .ensureLoaded(); + expect(loaded, isNull); + expect( + container.read(cloudStorageProviderProvider), + isNot(isA()), + ); + }, + ); + + test('setUnlocked activates the wrap; clear() reverts to raw', () async { + final container = await makeContainer(encryptionEnabled: true); + final notifier = container.read(encryptionKeyNotifierProvider.notifier); + await keyStore.saveKey(libraryKeyId: keyId, mlkBytes: mlkBytes); + final key = (await keyStore.loadKey())!; + await notifier.setUnlocked(key); + expect( + container.read(cloudStorageProviderProvider), + isA(), + ); + await notifier.clear(); + expect( + container.read(cloudStorageProviderProvider), + isNot(isA()), + ); + }); + + test( + 'clear() does not memoize null: ensureLoaded reloads afterwards', + () async { + // Regression for the review note: clear() must reset the memoized load so + // a later ensureLoaded() re-reads secure storage (e.g. a re-enable in the + // same container). With the key still present and the flag on, the reload + // must produce a fresh session rather than the memoized null. + final container = await makeContainer(encryptionEnabled: true); + final notifier = container.read(encryptionKeyNotifierProvider.notifier); + await keyStore.saveKey(libraryKeyId: keyId, mlkBytes: mlkBytes); + + final first = await notifier.ensureLoaded(); + expect(first, isNotNull); + + await notifier.clear(); + expect(container.read(encryptionKeyNotifierProvider), isNull); + + final reloaded = await notifier.ensureLoaded(); + expect( + reloaded, + isNotNull, + reason: 'ensureLoaded must re-read the store after clear()', + ); + expect( + container.read(cloudStorageProviderProvider), + isA(), + ); + }, + ); +} diff --git a/test/features/settings/presentation/widgets/encryption_passphrase_dialog_test.dart b/test/features/settings/presentation/widgets/encryption_passphrase_dialog_test.dart new file mode 100644 index 000000000..b3891c65c --- /dev/null +++ b/test/features/settings/presentation/widgets/encryption_passphrase_dialog_test.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/features/settings/presentation/widgets/encryption_passphrase_dialog.dart'; + +import '../../../../helpers/test_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Mounts a button that opens [dialog] and records the resolved value. + Future pumpOpener( + WidgetTester tester, + Future Function(BuildContext context) dialog, + List sink, + ) async { + await tester.pumpWidget( + testApp( + child: Builder( + builder: (context) => TextButton( + onPressed: () async => sink.add(await dialog(context)), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + group('showEncryptionPassphraseDialog', () { + testWidgets('submits the secret and pops with it', (tester) async { + final results = []; + String? seen; + await pumpOpener( + tester, + (context) => showEncryptionPassphraseDialog( + context, + title: 'Enter key', + onSubmit: (secret) async => seen = secret, + ), + results, + ); + + await tester.enterText(find.byType(TextField), 'my secret'); + await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); + await tester.pumpAndSettle(); + + expect(seen, 'my secret'); + expect(results.single, 'my secret'); + }); + + testWidgets('empty input does not call onSubmit', (tester) async { + var calls = 0; + await pumpOpener( + tester, + (context) => showEncryptionPassphraseDialog( + context, + title: 'Enter key', + onSubmit: (_) async => calls++, + ), + [], + ); + + await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); + await tester.pumpAndSettle(); + expect(calls, 0); + // Dialog stays open. + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('wrong passphrase shows inline error and stays open', ( + tester, + ) async { + await pumpOpener( + tester, + (context) => showEncryptionPassphraseDialog( + context, + title: 'Enter key', + onSubmit: (_) async => throw const WrongPassphraseException(), + ), + [], + ); + + await tester.enterText(find.byType(TextField), 'bad'); + await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); + await tester.pumpAndSettle(); + + expect( + find.text('Incorrect passphrase or recovery code'), + findsOneWidget, + ); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('generic error is surfaced inline', (tester) async { + await pumpOpener( + tester, + (context) => showEncryptionPassphraseDialog( + context, + title: 'Enter key', + onSubmit: (_) async => throw Exception('boom'), + ), + [], + ); + + await tester.enterText(find.byType(TextField), 'x'); + await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); + await tester.pumpAndSettle(); + + expect(find.textContaining('boom'), findsOneWidget); + }); + + testWidgets('cancel returns null without calling onSubmit', (tester) async { + final results = []; + var calls = 0; + await pumpOpener( + tester, + (context) => showEncryptionPassphraseDialog( + context, + title: 'Enter key', + onSubmit: (_) async => calls++, + ), + results, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(calls, 0); + expect(results.single, isNull); + }); + + testWidgets('submitting via the keyboard action works', (tester) async { + String? seen; + await pumpOpener( + tester, + (context) => showEncryptionPassphraseDialog( + context, + title: 'Enter key', + onSubmit: (secret) async => seen = secret, + ), + [], + ); + + await tester.enterText(find.byType(TextField), 'kbd'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(seen, 'kbd'); + }); + }); + + group('showChangePassphraseDialog', () { + Future openChange( + WidgetTester tester, + Future Function(String current, String next) onSubmit, + List sink, + ) => pumpOpener( + tester, + (context) => showChangePassphraseDialog(context, onSubmit: onSubmit), + sink, + ); + + Finder fieldFor(String label) => find.widgetWithText(TextField, label); + + testWidgets('too-short new passphrase blocks submit', (tester) async { + var calls = 0; + await openChange(tester, (_, _) async => calls++, []); + + await tester.enterText(fieldFor('Current passphrase'), 'oldsecret1'); + await tester.enterText(fieldFor('New passphrase'), 'short'); + await tester.enterText(fieldFor('Confirm passphrase'), 'short'); + await tester.tap(find.widgetWithText(FilledButton, 'Change passphrase')); + await tester.pumpAndSettle(); + + expect(find.text('Use at least 8 characters'), findsOneWidget); + expect(calls, 0); + }); + + testWidgets('mismatched confirm blocks submit', (tester) async { + var calls = 0; + await openChange(tester, (_, _) async => calls++, []); + + await tester.enterText(fieldFor('Current passphrase'), 'oldsecret1'); + await tester.enterText(fieldFor('New passphrase'), 'newsecret1'); + await tester.enterText(fieldFor('Confirm passphrase'), 'different1'); + await tester.tap(find.widgetWithText(FilledButton, 'Change passphrase')); + await tester.pumpAndSettle(); + + expect(find.text('Passphrases do not match'), findsOneWidget); + expect(calls, 0); + }); + + testWidgets('valid change calls onSubmit and returns true', (tester) async { + final results = []; + String? gotCurrent; + String? gotNext; + await openChange(tester, (current, next) async { + gotCurrent = current; + gotNext = next; + }, results); + + await tester.enterText(fieldFor('Current passphrase'), 'oldsecret1'); + await tester.enterText(fieldFor('New passphrase'), 'newsecret1'); + await tester.enterText(fieldFor('Confirm passphrase'), 'newsecret1'); + await tester.tap(find.widgetWithText(FilledButton, 'Change passphrase')); + await tester.pumpAndSettle(); + + expect(gotCurrent, 'oldsecret1'); + expect(gotNext, 'newsecret1'); + expect(results.single, isTrue); + }); + + testWidgets('wrong current passphrase shows an inline error', ( + tester, + ) async { + await openChange( + tester, + (_, _) async => throw const WrongPassphraseException(), + [], + ); + + await tester.enterText(fieldFor('Current passphrase'), 'wrong1234'); + await tester.enterText(fieldFor('New passphrase'), 'newsecret1'); + await tester.enterText(fieldFor('Confirm passphrase'), 'newsecret1'); + await tester.tap(find.widgetWithText(FilledButton, 'Change passphrase')); + await tester.pumpAndSettle(); + + expect( + find.text('Incorrect passphrase or recovery code'), + findsOneWidget, + ); + }); + + testWidgets('generic error surfaces on the current field', (tester) async { + await openChange( + tester, + (_, _) async => throw Exception('kaboom'), + [], + ); + + await tester.enterText(fieldFor('Current passphrase'), 'oldsecret1'); + await tester.enterText(fieldFor('New passphrase'), 'newsecret1'); + await tester.enterText(fieldFor('Confirm passphrase'), 'newsecret1'); + await tester.tap(find.widgetWithText(FilledButton, 'Change passphrase')); + await tester.pumpAndSettle(); + + expect(find.textContaining('kaboom'), findsOneWidget); + }); + + testWidgets('cancel returns false', (tester) async { + final results = []; + await openChange(tester, (_, _) async {}, results); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(results.single, isFalse); + }); + }); +} diff --git a/test/features/settings/presentation/widgets/encryption_settings_section_flows_test.dart b/test/features/settings/presentation/widgets/encryption_settings_section_flows_test.dart new file mode 100644 index 000000000..511478e15 --- /dev/null +++ b/test/features/settings/presentation/widgets/encryption_settings_section_flows_test.dart @@ -0,0 +1,386 @@ +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/providers/provider.dart' show StateNotifier; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/core/services/sync/crypto/keyslots.dart'; +import 'package:submersion/core/services/sync/crypto/sync_encryption_service.dart'; +import 'package:submersion/core/services/sync/library_epoch_store.dart'; +import 'package:submersion/core/services/sync/sync_preferences.dart'; +import 'package:submersion/features/backup/data/repositories/backup_preferences.dart'; +import 'package:submersion/features/backup/data/services/backup_service.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/encryption_settings_section.dart'; + +import '../../../../helpers/test_app.dart'; +import '../../../../support/fake_cloud_storage_provider.dart'; +import '../../../../support/fake_keychain_storage.dart'; + +const _passphrase = 'correct horse battery staple'; +const _keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; + +/// A fast, side-effect-light stand-in for the real service, so the SECTION's +/// orchestration can be exercised without running production Argon2id through +/// the UI. The real service is covered end-to-end in +/// sync_encryption_service_test.dart and encrypted_sync_integration_test.dart. +class _FakeEncryptionService implements SyncEncryptionService { + _FakeEncryptionService(this._keyStore, this._preferences); + + final EncryptionKeyStore _keyStore; + final SyncPreferences _preferences; + + int enableCalls = 0; + int disableCalls = 0; + int deleteKeyslotsCalls = 0; + int selfHealCalls = 0; + String? changedNewPassphrase; + bool unlockShouldFail = false; + + SecretKey _mlk() => SecretKey(List.generate(32, (i) => i)); + + @override + Future enable({ + required CloudStorageProvider rawProvider, + required String passphrase, + required LibraryEpochStore epochStore, + required String deviceId, + String? deviceName, + String? appVersion, + KdfParams kdf = const KdfParams(), + }) async { + enableCalls++; + await _keyStore.saveKey( + libraryKeyId: _keyId, + mlkBytes: await _mlk().extractBytes(), + ); + await _preferences.setSyncEncryptionEnabled(true); + return const EnableEncryptionResult( + recoveryCode: 'acid-acorn-acre-act-add-age-aid-aim', + libraryKeyId: _keyId, + ); + } + + @override + Future unlock({ + required CloudStorageProvider rawProvider, + required String secret, + }) async { + if (unlockShouldFail) throw const WrongPassphraseException(); + await _keyStore.saveKey( + libraryKeyId: _keyId, + mlkBytes: await _mlk().extractBytes(), + ); + return UnlockedKey(libraryKeyId: _keyId, mlk: _mlk()); + } + + @override + Future disable({ + required LibraryEpochStore epochStore, + required String deviceId, + String? deviceName, + String? appVersion, + }) async { + disableCalls++; + await _preferences.setSyncEncryptionEnabled(false); + } + + @override + Future deleteCloudKeyslots(CloudStorageProvider rawProvider) async { + deleteKeyslotsCalls++; + } + + @override + Future changePassphrase({ + required CloudStorageProvider rawProvider, + required String currentSecret, + required String newPassphrase, + KdfParams kdf = const KdfParams(), + }) async { + changedNewPassphrase = newPassphrase; + } + + @override + Future regenerateRecoveryCode({ + required CloudStorageProvider rawProvider, + required String passphrase, + KdfParams kdf = const KdfParams(), + }) async => 'new-code-one-two-three-four-five-six'; + + @override + Future selfHealKeyslots(CloudStorageProvider rawProvider) async { + selfHealCalls++; + } +} + +/// Returns a fixed device id so _markerIdentity() never touches a database. +class _FakeSyncRepository extends SyncRepository { + @override + Future getDeviceId() async => 'device-a'; +} + +/// Records performSync so the flows can assert a sync was triggered. +class _RecordingSyncNotifier extends StateNotifier + implements SyncNotifier { + _RecordingSyncNotifier() : super(const SyncState()); + int performSyncCalls = 0; + + @override + Future performSync({bool auto = false}) async => performSyncCalls++; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _NoopBackupAdapter implements BackupDatabaseAdapter { + @override + Future backup(String destinationPath) async {} + @override + Future restore(String backupPath) async {} + @override + Future get databasePath async => '/noop'; + @override + AppDatabase get database => throw UnimplementedError(); +} + +class _RecordingBackupService extends BackupService { + _RecordingBackupService(BackupPreferences prefs) + : super(dbAdapter: _NoopBackupAdapter(), preferences: prefs); + int deleteCalls = 0; + + @override + Future deletePlaintextCloudBackups() async { + deleteCalls++; + return 0; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + // _markerIdentity() reads the app version; stub it so the platform channel + // never hangs the enable/disable flows in a widget test. + PackageInfo.setMockInitialValues( + appName: 'Submersion', + packageName: 'app.submersion', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + }); + + late FakeCloudStorageProvider cloud; + late EncryptionKeyStore keyStore; + late SharedPreferences prefs; + late _RecordingSyncNotifier syncNotifier; + late _RecordingBackupService backupService; + late _FakeEncryptionService service; + + Future setUpProviders({ + required bool encryptionEnabled, + bool withStoredKey = false, + }) async { + SharedPreferences.setMockInitialValues({ + 'sync_encryption_enabled': encryptionEnabled, + }); + prefs = await SharedPreferences.getInstance(); + cloud = FakeCloudStorageProvider(); + keyStore = EncryptionKeyStore(storage: InMemoryKeychain()); + if (withStoredKey) { + await keyStore.saveKey( + libraryKeyId: _keyId, + mlkBytes: List.generate(32, (i) => i), + ); + } + syncNotifier = _RecordingSyncNotifier(); + backupService = _RecordingBackupService(BackupPreferences(prefs)); + service = _FakeEncryptionService(keyStore, SyncPreferences(prefs)); + } + + Widget wrap({bool nullProvider = false}) => testApp( + child: const SingleChildScrollView(child: EncryptionSettingsSection()), + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + encryptionKeyStoreProvider.overrideWithValue(keyStore), + syncEncryptionServiceProvider.overrideWithValue(service), + cloudStorageProviderProvider.overrideWithValue( + nullProvider ? null : cloud, + ), + syncStateProvider.overrideWith((ref) => syncNotifier), + syncRepositoryProvider.overrideWithValue(_FakeSyncRepository()), + backupServiceProvider.overrideWithValue(backupService), + ], + ); + + testWidgets('enable flow: fills passphrase, saves recovery, deletes ' + 'plaintext backups, and syncs', (tester) async { + await setUpProviders(encryptionEnabled: false); + await tester.pumpWidget(wrap()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Enable encryption')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Passphrase'), + _passphrase, + ); + await tester.enterText( + find.widgetWithText(TextField, 'Confirm passphrase'), + _passphrase, + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + expect(service.enableCalls, 1); + expect(find.text('I have saved my recovery code'), findsOneWidget); + await tester.tap(find.text('I have saved my recovery code')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Done')); + await tester.pumpAndSettle(); + + expect(backupService.deleteCalls, 1); + expect(syncNotifier.performSyncCalls, 1); + expect(await keyStore.loadKey(), isNotNull); + }); + + testWidgets('unlock flow on a locked device recovers the key and syncs', ( + tester, + ) async { + await setUpProviders(encryptionEnabled: true); + await tester.pumpWidget(wrap()); + await tester.pumpAndSettle(); + + expect(find.text('Encrypted — passphrase needed'), findsOneWidget); + await tester.tap(find.text('Enter passphrase')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), _passphrase); + await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); + await tester.pumpAndSettle(); + + expect(await keyStore.loadKey(), isNotNull); + expect(syncNotifier.performSyncCalls, 1); + }); + + testWidgets('unlock flow shows an error on a wrong passphrase', ( + tester, + ) async { + await setUpProviders(encryptionEnabled: true); + service.unlockShouldFail = true; + await tester.pumpWidget(wrap()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Enter passphrase')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'nope'); + await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); + await tester.pumpAndSettle(); + + expect(find.text('Incorrect passphrase or recovery code'), findsOneWidget); + expect(syncNotifier.performSyncCalls, 0); + }); + + testWidgets('change passphrase flow calls the service with the new secret', ( + tester, + ) async { + await setUpProviders(encryptionEnabled: true, withStoredKey: true); + await tester.pumpWidget(wrap()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Change passphrase')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Current passphrase'), + _passphrase, + ); + await tester.enterText( + find.widgetWithText(TextField, 'New passphrase'), + 'a whole new passphrase', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Confirm passphrase'), + 'a whole new passphrase', + ); + await tester.tap(find.widgetWithText(FilledButton, 'Change passphrase')); + await tester.pumpAndSettle(); + + expect(service.changedNewPassphrase, 'a whole new passphrase'); + }); + + testWidgets('regenerate recovery code shows the new code', (tester) async { + await setUpProviders(encryptionEnabled: true, withStoredKey: true); + await tester.pumpWidget(wrap()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Generate new recovery code')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), _passphrase); + await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); + await tester.pumpAndSettle(); + + expect(find.text('new-code-one-two-three-four-five-six'), findsOneWidget); + expect(find.text('I have saved my recovery code'), findsOneWidget); + await tester.tap(find.text('I have saved my recovery code')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Done')); + await tester.pumpAndSettle(); + }); + + testWidgets('disable flow confirms, clears the session, deletes keyslots, ' + 'and syncs', (tester) async { + await setUpProviders(encryptionEnabled: true, withStoredKey: true); + await tester.pumpWidget(wrap()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Turn off encryption')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Turn off encryption')); + await tester.pumpAndSettle(); + + expect(service.disableCalls, 1); + expect(service.deleteKeyslotsCalls, 1); + expect(syncNotifier.performSyncCalls, 1); + expect(prefs.getBool('sync_encryption_enabled'), isFalse); + }); + + testWidgets('disable can be cancelled', (tester) async { + await setUpProviders(encryptionEnabled: true, withStoredKey: true); + await tester.pumpWidget(wrap()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Turn off encryption')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(service.disableCalls, 0); + expect(syncNotifier.performSyncCalls, 0); + }); + + testWidgets('unlock with no cloud provider bails without a dialog or sync', ( + tester, + ) async { + // Locked state but the provider resolves to null (custom-folder mode). + // Tapping unlock must NOT open a dialog or report a spurious success. + await setUpProviders(encryptionEnabled: true); + await tester.pumpWidget(wrap(nullProvider: true)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Enter passphrase')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(FilledButton, 'Unlock'), findsNothing); + expect(syncNotifier.performSyncCalls, 0); + }); +} diff --git a/test/features/settings/presentation/widgets/encryption_settings_section_test.dart b/test/features/settings/presentation/widgets/encryption_settings_section_test.dart new file mode 100644 index 000000000..3e6d92237 --- /dev/null +++ b/test/features/settings/presentation/widgets/encryption_settings_section_test.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/services/sync/crypto/encryption_key_store.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/enable_encryption_dialog.dart'; +import 'package:submersion/features/settings/presentation/widgets/encryption_settings_section.dart'; + +import '../../../../helpers/test_app.dart'; +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const keyId = '8f14e45f-ceea-467f-ab37-a10a8d5f4c11'; + + Future> makeOverrides({ + required bool encryptionEnabled, + bool withStoredKey = false, + }) async { + SharedPreferences.setMockInitialValues({ + 'sync_encryption_enabled': encryptionEnabled, + }); + final prefs = await SharedPreferences.getInstance(); + final keyStore = EncryptionKeyStore(storage: InMemoryKeychain()); + if (withStoredKey) { + await keyStore.saveKey( + libraryKeyId: keyId, + mlkBytes: List.generate(32, (i) => i), + ); + } + return [ + sharedPreferencesProvider.overrideWithValue(prefs), + encryptionKeyStoreProvider.overrideWithValue(keyStore), + ]; + } + + Widget wrap(Widget child, List overrides) => testApp( + child: SingleChildScrollView(child: child), + overrides: overrides, + ); + + group('EncryptionSettingsSection states', () { + testWidgets('off shows the Enable action', (tester) async { + final overrides = await makeOverrides(encryptionEnabled: false); + await tester.pumpWidget( + wrap(const EncryptionSettingsSection(), overrides), + ); + await tester.pump(); + // No provider selected -> tile present but disabled with the hint. + expect(find.text('Enable encryption'), findsOneWidget); + expect(find.text('Select a cloud provider first'), findsOneWidget); + }); + + testWidgets('on + stored key shows manage actions', (tester) async { + final overrides = await makeOverrides( + encryptionEnabled: true, + withStoredKey: true, + ); + await tester.pumpWidget( + wrap(const EncryptionSettingsSection(), overrides), + ); + // Let the initState ensureLoaded() microtask + HKDF complete. + await tester.pumpAndSettle(); + expect(find.text('Encryption is on'), findsOneWidget); + expect(find.text('Change passphrase'), findsOneWidget); + expect(find.text('Turn off encryption'), findsOneWidget); + }); + + testWidgets('on + no key shows the locked state with unlock action', ( + tester, + ) async { + final overrides = await makeOverrides(encryptionEnabled: true); + await tester.pumpWidget( + wrap(const EncryptionSettingsSection(), overrides), + ); + await tester.pumpAndSettle(); + expect(find.text('Encrypted — passphrase needed'), findsOneWidget); + expect(find.text('Enter passphrase'), findsOneWidget); + }); + }); + + group('EnableEncryptionDialog', () { + testWidgets('mismatched confirm shows an error and never enables', ( + tester, + ) async { + var enableCalls = 0; + await tester.pumpWidget( + wrap( + Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => EnableEncryptionDialog( + onEnable: (_) async { + enableCalls++; + return 'code'; + }, + onFinished: (_) async {}, + ), + ), + child: const Text('open'), + ), + ), + const [], + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Passphrase'), + 'longenough1', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Confirm passphrase'), + 'different1', + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + expect(find.text('Passphrases do not match'), findsOneWidget); + expect(enableCalls, 0); + }); + + testWidgets('happy path reaches the recovery step; Done gated on the ' + 'saved checkbox', (tester) async { + final finishedWith = []; + await tester.pumpWidget( + wrap( + Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => EnableEncryptionDialog( + onEnable: (_) async => 'acid-acorn-acre-act', + onFinished: (delete) async => finishedWith.add(delete), + ), + ), + child: const Text('open'), + ), + ), + const [], + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Passphrase'), + 'longenough1', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Confirm passphrase'), + 'longenough1', + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + expect(find.text('acid-acorn-acre-act'), findsOneWidget); + final doneButton = find.widgetWithText(FilledButton, 'Done'); + expect(tester.widget(doneButton).onPressed, isNull); + + await tester.tap(find.text('I have saved my recovery code')); + await tester.pumpAndSettle(); + expect(tester.widget(doneButton).onPressed, isNotNull); + + await tester.tap(doneButton); + await tester.pumpAndSettle(); + expect(finishedWith, [true], reason: 'delete-backups defaults to on'); + }); + + testWidgets('too-short passphrase blocks Continue', (tester) async { + var enableCalls = 0; + await tester.pumpWidget( + wrap( + Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => EnableEncryptionDialog( + onEnable: (_) async { + enableCalls++; + return 'code'; + }, + onFinished: (_) async {}, + ), + ), + child: const Text('open'), + ), + ), + const [], + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Passphrase'), + 'short', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Confirm passphrase'), + 'short', + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + expect(find.text('Use at least 8 characters'), findsOneWidget); + expect(enableCalls, 0); + }); + + testWidgets('onEnable failure returns to the form with an error', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => EnableEncryptionDialog( + onEnable: (_) async => throw Exception('upload failed'), + onFinished: (_) async {}, + ), + ), + child: const Text('open'), + ), + ), + const [], + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Passphrase'), + 'longenough1', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Confirm passphrase'), + 'longenough1', + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + // Back on the form (Continue still present) with the error surfaced. + expect(find.textContaining('upload failed'), findsOneWidget); + expect(find.text('Continue'), findsOneWidget); + }); + + testWidgets('cancel closes the dialog without enabling', (tester) async { + var enableCalls = 0; + await tester.pumpWidget( + wrap( + Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => EnableEncryptionDialog( + onEnable: (_) async { + enableCalls++; + return 'code'; + }, + onFinished: (_) async {}, + ), + ), + child: const Text('open'), + ), + ), + const [], + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(EnableEncryptionDialog), findsNothing); + expect(enableCalls, 0); + }); + }); +} diff --git a/test/fixtures/crypto/crypto_vectors.json b/test/fixtures/crypto/crypto_vectors.json new file mode 100644 index 000000000..66e902aa3 --- /dev/null +++ b/test/fixtures/crypto/crypto_vectors.json @@ -0,0 +1,36 @@ +{ + "aesGcm": { + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "nonce": "AAECAwQFBgcICQoL", + "aad": "ssv1.devA.cs.00042.json", + "plaintext": "eyJoZWxsbyI6InN1Ym1lcnNpb24iLCJuIjo0Mn0=", + "ciphertextWithTag": "PCC+fqmJrTm3Y+T+04QdH/C/6FrSV30SGl3Rt2Aeidl2RDTOi8NWcVV6vi/R" + }, + "hkdfData": { + "ikm": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "info": "sbe:v1:data", + "output": "RMeFIbxOD7Yj8RkfoSFhG4yW+SgqW9y0WMwoxs90lo8=" + }, + "argon2id": { + "password": "correct horse battery staple", + "salt": "AAECAwQFBgcICQoLDA0ODw==", + "m": 1024, + "t": 3, + "p": 1, + "output": "WQ193Teh7YZBejYUvkfJ94KCijiQI5ABjTm7ZVb+4Ck=" + }, + "envelopePlain": { + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "keyId": "8f14e45f-ceea-467f-ab37-a10a8d5f4c11", + "filename": "ssv1.devA.cs.00042.json", + "plaintext": "eyJoZWxsbyI6InN1Ym1lcnNpb24iLCJuIjo0Mn0=", + "envelope": "U0JFMY8U5F/O6kZ/qzehCo1fTBEAAAECAwQFBgcICQoLPCC+fqmJrTm3Y+T+04QdH/C/6FrSV30SGl3Rt2Aeidl2RDTOi8NWcVV6vi/R" + }, + "envelopeGzip": { + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "keyId": "8f14e45f-ceea-467f-ab37-a10a8d5f4c11", + "filename": "ssv1.devA.cs.00042.json", + "plaintext": "eyJoZWxsbyI6InN1Ym1lcnNpb24iLCJuIjo0Mn0=", + "envelope": "U0JFMY8U5F/O6kZ/qzehCo1fTBEBAAECAwQFBgcICQoLWIneG8XlwhuPvjzde6G1pEqBNWbaVRK2dUrPS9GmU2BQ2v1OnfC4nXTKpYZtmig47rTRwHnq+Z8+EslLSi9dxl4=" + } +} \ No newline at end of file diff --git a/test/support/fake_cloud_storage_provider.dart b/test/support/fake_cloud_storage_provider.dart index 132bdb984..d6e6e6ad1 100644 --- a/test/support/fake_cloud_storage_provider.dart +++ b/test/support/fake_cloud_storage_provider.dart @@ -111,6 +111,16 @@ class FakeCloudStorageProvider implements CloudStorageProvider { return out; } + /// Raw stored bytes (as the provider sees them), for encryption + /// leak-invariant assertions. Names are the final path segment. + List<({String name, Uint8List bytes})> allStoredFiles() => [ + for (final e in _files.entries) + ( + name: e.key.substring(e.key.lastIndexOf('/') + 1), + bytes: Uint8List.fromList(e.value), + ), + ]; + @override Future deleteFile(String fileId) async { _files.remove(fileId);