Files
buzz/mobile/lib/shared/hosted/hpke_backup.dart
Tom Brow 8161a7cad0 perf(mobile): derive public keys with native libsecp256k1 (#8655)
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>
2026-10-29 08:53:58 -07:00

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