Files
buzz/mobile/lib/shared/hosted/hpke_backup.dart
Wes 75854111b8 fix(mobile): retain invite keys and reject incomplete onboarding (#8753)
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>
2026-10-30 10:38:24 -06:00

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;
}
}