Files
buzz/mobile/lib/shared/me/default_agent_writer.dart
Tom Brow 9c5032b63b perf(mobile): derive keys natively and hash NIP-98 bodies faster (#8965)
## Summary

Two pure-Dart crypto paths that run on the UI isolate during normal use:

- **NIP-98 body hashes.** Every relay HTTP query
(`RelaySessionNotifier._postForEvents`) hashes its request body for the
NIP-98 auth header with pointycastle's `SHA256Digest`. This switches
that hash, the media upload hash and the NIP-OA preimage hash to
`package:crypto`'s `sha256`, which is already a dependency. The output
is identical.
- **Public key derivation.** `AccountIdentityStore.publicKeyForNsec`
runs on every relay auth assertion and derived the key with the `nostr`
package's pure-Dart curve math. It now uses the native
`derivePublicKeyHex` (libsecp256k1) with the same validation. The
default-agent writer now signs with the native `signNostrEvent` instead
of `nostr.Event.from`.

Left as they were: `nostr.Keys` construction for signing keys (nostr
2.0.0 always derives in Dart, with no way to pass a known public key),
and `nostr.Keys.generate()` on the rare key-creation paths.

## Measurements

- **Device** (iPhone 12 mini, Profile build, #4things captures): NIP-98
hashing took 17 to 24 ms of UI CPU per 10 to 20 s capture, mostly from
channel notices queries. `publicKeyForNsec` took about 7 ms per relay
auth assertion.
- **Synthetic** (Mac, AOT, same output): SHA-256 of a 1 KB body takes
39.9 µs with pointycastle and 6.6 µs with `package:crypto`. 50 KB: 1,518
µs vs 293 µs. 1 MB: 29.6 ms vs 6.1 ms.

- **Device A/B after the change** (iPhone 12 mini, Profile/AOT, Tom's
Block community, two app resumes per build from the home screen, 15 s
windows; A is `origin/main` 3d26ed7, B is A plus this PR and
[#8966](https://github.com/block/buzz/pull/8966)): NIP-98 signing per
resume went from 42 / 50 ms to 9 / 13 ms of UI CPU, and all SHA-256 work
per resume from 74 / 73 ms to 33 / 33 ms. Dart public-key derivation
through `nostr.Keys` (left as is, see above) still takes 15 to 23 ms per
resume.
- **Same A/B on current `main`** (14037e3): pointycastle SHA-256 per
resume went from 39 / 45 ms to 7 / 0 ms, and NIP-98 header building from
4 / 5 ms to 2 / 0 ms. `nostr.Keys` derivation: 12 to 17 ms per resume on
both builds.

## Testing

- New known-vector tests for the NIP-98 header hash: an empty body,
`abc`, and the one-million-`a` FIPS vector. They also check the event's
kind, method, signature and pubkey.
- New `publicKeyForNsec` tests: the generator-point vector, matching the
pure-Dart derivation, and rejecting invalid keys (scalar 0 and n,
garbage, an npub).
- These are correctness tests only. A timing test would be flaky, so
none would fail if the old pure-Dart code came back.
- `just mobile-check mobile-test` passes (pre-push hook).
- Automated device run in place of manual testing: the B build above on
the 12 mini, launched and resumed twice into a signed-in Block
community, loading the channel list, channel notices and read state over
authenticated relay queries (NIP-98 headers and relay auth assertions on
every request). Not exercised on the device: media upload and the
default-agent writer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:23:47 -08:00

70 lines
2.0 KiB
Dart

import 'dart:convert';
import '../crypto/event_signing.dart';
import '../crypto/nip44.dart';
import '../relay/relay.dart';
import 'default_agent.dart';
import 'me_sync.dart';
/// Publishes and confirms the owner's encrypted, community-scoped choice.
/// Failures propagate to the selection UI so the user can retry.
Future<void> saveDefaultAgent(
MeSync sync,
String? pubkey, {
bool onlyIfUnset = false,
}) async {
if (pubkey != null && !RegExp(r'^[0-9a-f]{64}$').hasMatch(pubkey)) {
throw const FormatException('Invalid agent public key');
}
sync.check();
final filter = NostrFilter(
kinds: const [30078],
authors: [sync.pubkey],
tags: {
'#d': [defaultAgentCoordinate(sync.community)],
},
limit: 1,
);
final previous = await sync.session.fetchHistory(filter);
sync.check();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
for (final event in previous) {
decodeDefaultAgent(event, sync);
if (onlyIfUnset) return;
if (event.createdAt >= now) {
throw StateError('Please wait a second before saving again.');
}
}
final event = NostrEvent.fromJson(
signNostrEvent(
kind: 30078,
content: nip44Encrypt(
sync.key,
jsonEncode({'version': 1, 'agent': pubkey}),
),
secretKey: sync.privateHex,
pubkey: sync.pubkey,
createdAt: now,
tags: [
['d', defaultAgentCoordinate(sync.community)],
['t', defaultAgentTag],
],
).toMap(),
);
await sync.session.publish(event);
sync.check();
final confirmed = await sync.session.fetchHistory(filter);
sync.check();
if (confirmed.isEmpty || confirmed.first.id != event.id) {
throw StateError('The Bestie changed on another device. Try again.');
}
final prefs = sync.prefs;
if (prefs != null &&
!await prefs.setString(
'main-agent:${sync.community}:${sync.pubkey}',
jsonEncode(event.toJson()),
)) {
throw StateError('Bestie saved, but offline storage failed. Try again.');
}
}