mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
Self-hosted invite claims retain the generated identity in secure storage before the HTTP request and reuse it after a lost response or restart. Saving or reopening an existing destination retires the matching journal. Sign-out/removal deletes all matching pending key copies before removing the saved community, preserving the community for retry if cleanup fails. Pairing verifies that relay authentication completed before saving/activating a destination or reporting success. On iOS 16, hosted entry permits recovery and reuse of backed-up identities, including binding a recovered identity with a confirmed backup receipt. Paths requiring a new secure backup explain the iOS 17 requirement before generating or binding a new key. Self-hosted entry remains available. ### Validation - Initial focused entry/recovery files: 83 passed, 1 skipped. Review-fix focused storage, community, invite and hosted files: 82 passed, 1 skipped; extended invite journey: 17 passed, 1 skipped. - Final hosted journey extension: 20 passed, including both bound and unbound recovery with native sealing unavailable. - Isolated mutations removing invite retention, pairing validation, journal removal/re-entry cleanup or restoring the blanket platform gate fail their intended regressions. Restored controls pass. - iOS simulator build passed on the original integration base; final native/device acceptance remains outstanding. - Carl and three specialist reviewers found no source-review blockers at `323cec6ea2`. `a55a246857` adds analyzer fixes, copy cleanup and the recovered-unbound journey coverage. Reviewers did not run tests or the app. - Fixed a push-hook timeout in `4564b3562a`: idle serial queues now release completed futures, avoiding scheduler retention across widget tests while preserving in-flight ordering and failure isolation. The existing dispatcher journey fails promptly before the fix and passes afterward; Carl reviewed this delta with no blockers. Focused dispatcher/invite/storage checks passed, and all 20 hosted journeys passed with hosted auth enabled. - Mandatory pre-push checks passed at `4564b3562a` (scope, branch skew, file size, mobile analysis/tests and crypto package checks); GitHub head verified at that commit. ### Remaining acceptance Draft pending agent/device and human acceptance. Exercise interrupted one-use invite claim and retry across restart, sign-out then reopen without restoring the removed identity, rejected self-hosted pairing, iOS 16 backed-up identity recovery (including binding), and iOS 16 new setup blocked before key creation. Synthetic network/storage boundaries do not prove native durability or live server behavior. Hosted CI results are not claimed here. Optional follow-ups remain outside this patch: pruning provably unadmitted journals, malformed-record recovery, avoiding an iOS 16 dead-end device-key selection, and pre-existing removal/push-revocation ordering. This PR contains three existing-path fixes. The two organization-login fixes depend on #8700 and remain separate: its stale parent conflicts with main in unrelated relay code, and the required branch-skew hook prevents publishing that stack unchanged. This PR does not refresh that parent or change deployment policy. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
147 lines
4.9 KiB
Dart
147 lines
4.9 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
/// Never include input, plaintext, or native exception details in this error.
|
|
enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable }
|
|
|
|
/// Seal only enrollment obtained from the fixed-origin authenticated account
|
|
/// client. Never pass recipient material from a relay, deep link or QR payload.
|
|
/// Persist the returned envelope BEFORE upload; retry its exact backup_id and
|
|
/// ciphertext instead of enrolling/sealing again after uncertain responses.
|
|
class HpkeBackup {
|
|
static const _channel = MethodChannel('buzz/hosted_hpke');
|
|
|
|
/// Existing backups can be recovered on iOS 16, but new ones require iOS 17.
|
|
static Future<bool> supportsSealing() async {
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) return true;
|
|
return await _channel.invokeMethod<bool>('isSupported') == true;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> seal({
|
|
required Map<String, dynamic> enrollment,
|
|
required String secretHex,
|
|
}) async {
|
|
try {
|
|
final pubkey = derivePublicKeyHex(secretHex);
|
|
if (enrollment['nostr_pubkey'] != pubkey) {
|
|
throw HpkeBackupFailure.identityMismatch;
|
|
}
|
|
final aad = associatedData(enrollment);
|
|
final recipient = _decode(enrollment['recipient_public_key'] as String);
|
|
if (recipient.length != 65 || recipient.first != 4) {
|
|
throw HpkeBackupFailure.invalidEnrollment;
|
|
}
|
|
final plaintext = _hex(secretHex, 32);
|
|
final Map<dynamic, dynamic>? sealed;
|
|
try {
|
|
sealed = await _channel.invokeMapMethod<dynamic, dynamic>('seal', {
|
|
'recipient': recipient,
|
|
'plaintext': plaintext,
|
|
'aad': aad,
|
|
});
|
|
} finally {
|
|
plaintext.fillRange(0, plaintext.length, 0);
|
|
}
|
|
final enc = sealed?['enc'];
|
|
final ciphertext = sealed?['ciphertext'];
|
|
if (enc is! Uint8List ||
|
|
enc.length != 65 ||
|
|
enc.first != 4 ||
|
|
ciphertext is! Uint8List ||
|
|
ciphertext.length != 48) {
|
|
throw HpkeBackupFailure.unavailable;
|
|
}
|
|
return {
|
|
for (final field in [
|
|
'version',
|
|
'suite',
|
|
'recipient_key_id',
|
|
'service_namespace',
|
|
'owner_id',
|
|
'backup_id',
|
|
'nostr_pubkey',
|
|
])
|
|
field: enrollment[field],
|
|
'enc': _encode(enc),
|
|
'ciphertext': _encode(ciphertext),
|
|
};
|
|
} on HpkeBackupFailure {
|
|
rethrow;
|
|
} on PlatformException {
|
|
throw HpkeBackupFailure.unavailable;
|
|
} on MissingPluginException {
|
|
throw HpkeBackupFailure.unavailable;
|
|
} catch (_) {
|
|
throw HpkeBackupFailure.invalidEnrollment;
|
|
}
|
|
}
|
|
|
|
/// Exact v1 wire framing shared with desktop and the recovery service.
|
|
static Uint8List associatedData(Map<String, dynamic> enrollment) {
|
|
try {
|
|
final suite = enrollment['suite'] as Map;
|
|
if (enrollment['version'] != 1 ||
|
|
suite['kem_id'] != 16 ||
|
|
suite['kdf_id'] != 1 ||
|
|
suite['aead_id'] != 2) {
|
|
throw HpkeBackupFailure.invalidEnrollment;
|
|
}
|
|
final bytes = BytesBuilder(copy: false)
|
|
..add(utf8.encode('buzz/nsec-backup/aad/v1'))
|
|
..add([1, 0, 16, 0, 1, 0, 2]);
|
|
for (final field in [
|
|
'recipient_key_id',
|
|
'service_namespace',
|
|
'owner_id',
|
|
]) {
|
|
final value = enrollment[field] as String;
|
|
final encoded = utf8.encode(value);
|
|
if (encoded.isEmpty ||
|
|
encoded.length > 255 ||
|
|
value.runes.any((r) => r < 32 || (r >= 127 && r <= 159))) {
|
|
throw HpkeBackupFailure.invalidEnrollment;
|
|
}
|
|
bytes.add([0, encoded.length]);
|
|
bytes.add(encoded);
|
|
}
|
|
final id = enrollment['backup_id'] as String;
|
|
if (!RegExp(
|
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
|
).hasMatch(id)) {
|
|
throw HpkeBackupFailure.invalidEnrollment;
|
|
}
|
|
bytes.add(_hex(id.replaceAll('-', ''), 16));
|
|
bytes.add(_hex(enrollment['nostr_pubkey'] as String, 32));
|
|
return bytes.toBytes();
|
|
} on HpkeBackupFailure {
|
|
rethrow;
|
|
} catch (_) {
|
|
throw HpkeBackupFailure.invalidEnrollment;
|
|
}
|
|
}
|
|
|
|
static Uint8List _hex(String value, int length) {
|
|
if (value.length != length * 2 || !RegExp(r'^[0-9a-f]+$').hasMatch(value)) {
|
|
throw HpkeBackupFailure.invalidEnrollment;
|
|
}
|
|
return Uint8List.fromList(
|
|
List.generate(
|
|
length,
|
|
(i) => int.parse(value.substring(i * 2, i * 2 + 2), radix: 16),
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _encode(List<int> bytes) =>
|
|
base64UrlEncode(bytes).replaceAll('=', '');
|
|
static Uint8List _decode(String value) {
|
|
final decoded = base64Url.decode(base64Url.normalize(value));
|
|
if (_encode(decoded) != value) throw HpkeBackupFailure.invalidEnrollment;
|
|
return decoded;
|
|
}
|
|
}
|