Files
buzz/mobile/test/shared/relay/relay_session_auth_test.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

71 lines
2.2 KiB
Dart

import 'dart:convert';
import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:buzz/shared/relay/relay_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
final keys = nostr.Keys(
'b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef',
);
Map<String, dynamic> decode(String header) {
expect(header, startsWith('Nostr '));
return jsonDecode(utf8.decode(base64.decode(header.substring(6))))
as Map<String, dynamic>;
}
String payloadTag(Map<String, dynamic> event) =>
(event['tags'] as List).firstWhere((t) => t[0] == 'payload')[1] as String;
test(
'payload tag is the lowercase SHA-256 hex of the body (known vectors)',
() {
final vectors = {
'': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
'abc':
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
};
for (final entry in vectors.entries) {
final header = buildNip98AuthHeader(
method: 'post',
url: 'https://relay.example/query',
bodyBytes: utf8.encode(entry.key),
nsec: keys.nsec,
);
final event = decode(header);
expect(payloadTag(event), entry.value);
expect(event['kind'], 27235);
expect(event['pubkey'], keys.public);
expect(
(event['tags'] as List).firstWhere((t) => t[0] == 'method')[1],
'POST',
);
expect(
verifySchnorrHex(
publicKey: event['pubkey'] as String,
messageHex: event['id'] as String,
signature: event['sig'] as String,
),
isTrue,
);
}
},
);
test('hashes bodies longer than one SHA-256 block identically', () {
// 1,000,000 repetitions of "a" (FIPS 180 test vector).
final header = buildNip98AuthHeader(
method: 'POST',
url: 'https://relay.example/query',
bodyBytes: List.filled(1000000, 0x61),
nsec: keys.nsec,
);
expect(
payloadTag(decode(header)),
'cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0',
);
});
}