mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
## 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>
108 lines
3.7 KiB
Dart
108 lines
3.7 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
|
import 'package:crypto/crypto.dart' as crypto;
|
|
import '../relay/nostr_models.dart';
|
|
|
|
/// Verify the signed profile and all owner-attestation conditions against it.
|
|
String? verifiedProfileOaOwnerPubkey(NostrEvent event) {
|
|
if (event.kind != 0) return null;
|
|
final tags = event.tags
|
|
.where((tag) => tag.isNotEmpty && tag[0] == 'auth')
|
|
.toList();
|
|
if (tags.length != 1 || tags.single.length != 4) return null;
|
|
try {
|
|
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
|
|
if (owner == null) return null;
|
|
final conditions = tags.single[2];
|
|
for (final clause
|
|
in conditions.isEmpty ? <String>[] : conditions.split('&')) {
|
|
if (clause.startsWith('kind=')) {
|
|
if (int.parse(clause.substring(5)) != event.kind) return null;
|
|
} else if (clause.startsWith('created_at<')) {
|
|
if (event.createdAt >= int.parse(clause.substring(11))) return null;
|
|
} else if (clause.startsWith('created_at>')) {
|
|
if (event.createdAt <= int.parse(clause.substring(11))) return null;
|
|
}
|
|
}
|
|
return owner;
|
|
}
|
|
|
|
/// NIP-OA (Owner Attestation) — verify the `auth` tag on a kind:0 profile
|
|
/// that proves an owner key authorized an agent key.
|
|
///
|
|
/// Tag format: ["auth", "<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
|
|
/// Preimage: "nostr:agent-auth:" + agent_pubkey_hex + ":" + conditions
|
|
/// Signature: BIP-340 Schnorr over SHA256(preimage) by the owner key.
|
|
///
|
|
/// Mirrors `profile_valid_oa_owner_pubkey` in desktop/src-tauri: the tag is
|
|
/// verified against the profile event author, so a forged or stale marker
|
|
/// cannot turn a person into an agent.
|
|
///
|
|
/// Low-level tag verification. Use verifiedProfileOaOwnerPubkey for admission.
|
|
/// Returns the owner pubkey (lowercase hex) for exactly one valid auth tag,
|
|
/// or null if none verifies.
|
|
String? verifiedOaOwnerPubkey(List<List<String>> tags, String agentPubkey) {
|
|
final agent = agentPubkey.toLowerCase();
|
|
if (tags.where((tag) => tag.isNotEmpty && tag[0] == 'auth').length != 1) {
|
|
return null;
|
|
}
|
|
|
|
for (final tag in tags) {
|
|
if (tag.length != 4 || tag[0] != 'auth') continue;
|
|
|
|
final owner = tag[1];
|
|
if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(owner) ||
|
|
!RegExp(r'^[0-9a-f]{128}$').hasMatch(tag[3])) {
|
|
continue;
|
|
}
|
|
final conditions = tag[2];
|
|
final sig = tag[3];
|
|
|
|
// Self-attestation is meaningless and rejected.
|
|
if (owner == agent) continue;
|
|
if (owner.length != 64 || sig.length != 128) continue;
|
|
if (!_validConditions(conditions)) continue;
|
|
|
|
final preimage = utf8.encode('nostr:agent-auth:$agent:$conditions');
|
|
final message = crypto.sha256.convert(preimage).toString();
|
|
|
|
try {
|
|
if (verifySchnorrHex(
|
|
publicKey: owner,
|
|
messageHex: message,
|
|
signature: sig,
|
|
)) {
|
|
return owner;
|
|
}
|
|
} catch (_) {
|
|
// Malformed hex — treat as an invalid tag.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Validate the NIP-OA `conditions` string: empty, or `&`-joined clauses of
|
|
/// `kind=<n>`, `created_at<<n>`, or `created_at><n>` with canonical decimals.
|
|
bool _validConditions(String conditions) {
|
|
if (conditions.isEmpty) return true;
|
|
if (conditions.contains(RegExp(r'\s'))) return false;
|
|
|
|
for (final clause in conditions.split('&')) {
|
|
final match = RegExp(
|
|
r'^(?:kind=|created_at<|created_at>)(0|[1-9][0-9]*)$',
|
|
).firstMatch(clause);
|
|
if (match == null) return false;
|
|
final value = int.tryParse(match.group(1)!);
|
|
if (value == null || value > 4294967295) return false;
|
|
if (clause.startsWith('kind=') && value > 65535) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|