mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
Follows #8654. Deriving an x-only public key from a secret key with `nostr.Keys(secret).public` costs about 5.6 ms in pure Dart on an iPhone SE (2nd gen, Profile). The app does it in 10 places, including relay config, the signed-event relay's `pubkey` getter, push lease checks, status updates, and the settings identity row's `build` method. This routes all of them through `derivePublicKeyHex` from #8654, which takes 16.5 µs on the same device. Behavior is unchanged for valid keys: output is the same lowercase hex. For a malformed or out-of-range key the thrown type changes from nostr's `InvalidKeyException` to `ArgumentError`; every affected site either catches all errors or rethrows its own `FormatException`/`StateError`, and nothing in `lib` or `test` matches on the old type. Sites that need a full `nostr.Keys` object (key generation, `AccountIdentityStore`) are unchanged. ### Validation - `derivePublicKeyHex` is checked against `nostr.Keys` for 64 random keys in #8654's `event_signing_test.dart`. - Full mobile analyze and test suite pass on this branch after rebasing onto main, including the package's own tests. - iPhone 12 mini, Profile build of this stack (with #8656 and #8657 on top): paired to the internal Buzz community through Desktop; relay auth, identity row, avatar, and channel/DM/inbox loading all worked under the derived key. buzz-review-completed (agent review, Codex and security review clean; device validation above; human app test not applicable under the mobile testing exception) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
4.6 KiB
Dart
140 lines
4.6 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
|
import 'package:flutter/services.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');
|
|
|
|
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;
|
|
}
|
|
}
|