Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
61986a3
docs: encrypted cloud sync (E2E) design spec (#520)
ericgriffin Jul 10, 2026
6fad1bd
docs: encrypted cloud sync implementation plan + cryptography deps (#…
ericgriffin Jul 10, 2026
fa4ca19
feat(sync): SBE1 encrypted envelope with python-generated KAT vectors…
ericgriffin Jul 10, 2026
84f1f72
feat(sync): keyslot file with Argon2id passphrase/recovery wrapping (…
ericgriffin Jul 10, 2026
1d8ae4c
feat(sync): recovery code generation on the EFF short wordlist (#520)
ericgriffin Jul 10, 2026
a67d62c
feat(sync): local key custody, keyslot mirror, encryption preference …
ericgriffin Jul 10, 2026
994ece0
feat(sync): encrypting cloud storage decorator (#520)
ericgriffin Jul 10, 2026
ecf144d
feat(sync): awaitingPassphrase halt for encrypted libraries (#520)
ericgriffin Jul 10, 2026
7962986
feat(sync): encryption lifecycle service - enable, unlock, rotate slo…
ericgriffin Jul 10, 2026
344eeb9
feat(sync): riverpod wiring for encryption session and provider wrap …
ericgriffin Jul 10, 2026
50014ff
feat(backup): framed self-decrypting .sbe backup encryption (#520)
ericgriffin Jul 10, 2026
99940b1
feat(backup): encrypted cloud backup upload, restore, plaintext clean…
ericgriffin Jul 10, 2026
a39592f
test(backup): update restore-service doubles for the encryptionSecret…
ericgriffin Jul 10, 2026
30da9be
test(sync): no-plaintext-leak invariant and encrypted two-device conv…
ericgriffin Jul 10, 2026
6afdf1e
feat(settings): end-to-end encryption section with enable and manage …
ericgriffin Jul 10, 2026
1823276
feat(sync): unlock banner, troubleshoot status, encrypted restore pro…
ericgriffin Jul 10, 2026
09c56d3
l10n: translate sync encryption strings into all locales (#520)
ericgriffin Jul 10, 2026
2fa8031
fix(sync): address PR #548 review + raise patch coverage to 97% (#520)
ericgriffin Jul 10, 2026
960d133
docs: v1.6.1.115 release notes for encrypted cloud sync (#520)
ericgriffin Jul 11, 2026
fad12de
fix(sync): address PR #548 re-review comments (#520)
ericgriffin Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,476 changes: 2,476 additions & 0 deletions docs/superpowers/plans/2026-07-10-encrypted-cloud-sync.md

Large diffs are not rendered by default.

403 changes: 403 additions & 0 deletions docs/superpowers/specs/2026-07-10-encrypted-cloud-sync-design.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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<String, String> _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_');
Comment thread
ericgriffin marked this conversation as resolved.
Outdated

@override
Future<UploadResult> 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<Uint8List> 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,
);
}
Comment thread
ericgriffin marked this conversation as resolved.

@override
Future<List<CloudFileInfo>> 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<CloudFileInfo?> 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<bool> isAvailable() => inner.isAvailable();
@override
Future<bool> isAuthenticated() => inner.isAuthenticated();
@override
Future<void> authenticate() => inner.authenticate();
@override
Future<void> signOut() => inner.signOut();
@override
Future<String?> getUserEmail() => inner.getUserEmail();
@override
Future<void> deleteFile(String fileId) => inner.deleteFile(fileId);
@override
Future<bool> fileExists(String fileId) => inner.fileExists(fileId);
@override
Future<String> createFolder(String folderName, {String? parentFolderId}) =>
inner.createFolder(folderName, parentFolderId: parentFolderId);
@override
Future<String> getOrCreateSyncFolder() => inner.getOrCreateSyncFolder();
}
19 changes: 16 additions & 3 deletions lib/core/services/sync/changeset_log/sync_manifest.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<String, dynamic>,
);
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<String, dynamic>,
);
}
}
27 changes: 27 additions & 0 deletions lib/core/services/sync/crypto/crypto_errors.dart
Original file line number Diff line number Diff line change
@@ -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';
}
Loading