Files
buzz/mobile/lib/shared/crypto/nip44.dart
Tom Brow bad9d26142 fix(mobile): push every message in your own Solo chats (#8615)
## Summary

Mobile push only covered Solo chats through explicit mentions. Solo
chats are private streams rather than DMs, and the push lease subscribed
channel-wide only to DMs. Agent replies in your own Solo chats therefore
never woke the phone.

The lease now subscribes channel-wide to the viewer's own joined,
unarchived Solo chats, using the same predicate as the Solo tab,
including older unlabelled Solo chats listed in the encrypted Solo
record. Self-authored messages stay excluded through the existing lease
ignores. Muted Solo chats stay out of the lease, like muted channels, so
they push only on mention. The lease waits while the Solo record loads
so an unresolved record never drops legacy Solo chats. If that record
fails with no cached copy, DM and mention updates proceed without the
legacy chats, which the Solo tab cannot list either, and they return
when it recovers. Solo chats created by someone else still push only on
mention, matching how they appear in the channel list.

A lease is bounded by its channel count (750), by the relay's advertised
plaintext and content sizes, and by NIP-44's 65535-byte plaintext
ceiling. DMs always stay, as before. Solo chats fill the remaining room
most recent first, sized with the publisher's own lease encoding, the
exact NIP-44 payload length and the largest endpoint the relay accepts.
Sizes are computed rather than built, so a relay's advertised limits
never drive an allocation. Solo chats beyond the room push only on
mention instead of failing the lease update or publication, and
Notifications settings says how many. A relay too small even for DMs and
mentions fails push setup visibly instead of saving a lease it would
reject. Publication accepts only endpoint grants in the gateway's
JSON-safe alphabet, so the sizing assumption holds.

Thread replies outside DMs still do not push unless they mention you.
That is a separate gap.

### Related issue

None found.

### Validation

- Added test: the desired lease includes joined DMs and the viewer's own
Solo chats, and includes legacy Solo chats from the Solo record, and
excludes archived and muted Solo chats, other people's Solo chats, and
unlabelled private streams. It fails without the fix.
- Added test: the sync provider holds the desired lease while the Solo
record loads, proceeds without legacy chats if the record fails, and
adds them once it arrives. It fails if loading is treated as empty or if
a failure blocks updates.
- Added tests: over the channel count, and over each size limit (relay
plaintext, relay content, NIP-44 plaintext), the lease keeps every DM
and exactly the newest Solo chats, reports the rest as omitted, and is
accepted by `publishBuzzDevPushLease` with a grant at the relay's
endpoint limit, while a max-length grant that JSON would escape is
rejected as not a gateway grant. A relay advertising a 2^50-byte
endpoint limit is sized without allocating. Each row fails if its guard
is removed.
- Added test: a relay whose content or plaintext limit cannot fit even
the lease without Solo chats rejects that lease at publication, and the
provider fails setup instead of saving it. It fails if the guard is
removed.
- Added test: the NIP-44 payload length matches real encryption at every
padding boundary up to 65535 bytes.
- Added test: Notifications settings shows "Older Solo chats notify only
on mention" with the omitted count. It fails without the row.
- Mobile lint and the full mobile test suite pass.
- Not exercised end to end on a device. A signed dev build was installed
on a test iPhone against a personal dev relay, but the phone's dev
identity owns no assistant there, so the device could not create a Solo
chat without exporting its key. This will be validated in dogfood. The
relay matcher already handles channel-wide `#h` leases for DMs, and Solo
messages use the same kind and scoping.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:44:59 -07:00

179 lines
6.2 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/api.dart';
import 'package:pointycastle/digests/sha256.dart';
import 'package:pointycastle/macs/hmac.dart';
import 'package:pointycastle/stream/chacha7539.dart';
import 'ecdh.dart';
import 'hkdf.dart';
/// NIP-44 v2 conversation key derivation.
///
/// conversation_key = HKDF-Extract(salt="nip44-v2", ikm=ecdh_shared_secret)
Uint8List getConversationKey(String senderPrivHex, String receiverPubHex) {
final shared = ecdhSharedSecret(senderPrivHex, receiverPubHex);
final salt = utf8.encode('nip44-v2');
return hkdfExtract(Uint8List.fromList(salt), shared);
}
/// NIP-44 v2 encrypt.
///
/// Returns base64-encoded payload: version(1) || nonce(32) || ciphertext || mac(32)
String nip44Encrypt(Uint8List conversationKey, String plaintext) {
final plaintextBytes = utf8.encode(plaintext);
if (plaintextBytes.isEmpty || plaintextBytes.length > 65535) {
throw ArgumentError('Plaintext must be 1-65535 bytes');
}
final padded = _pad(Uint8List.fromList(plaintextBytes));
final nonce = secureRandomBytes(32);
// Derive message keys: chacha_key(32) + chacha_nonce(12) + hmac_key(32) = 76
final messageKeys = hkdfExpand(conversationKey, nonce, 76);
final chachaKey = Uint8List.sublistView(messageKeys, 0, 32);
final chachaNonce = Uint8List.sublistView(messageKeys, 32, 44);
final hmacKey = Uint8List.sublistView(messageKeys, 44, 76);
final ciphertext = _chacha20(chachaKey, chachaNonce, padded);
// HMAC-SHA256 over nonce || ciphertext.
final mac = _hmacSha256(hmacKey, _concat([nonce, ciphertext]));
// Assemble: version(0x02) || nonce(32) || ciphertext || mac(32)
final payload = _concat([
Uint8List.fromList([0x02]),
nonce,
ciphertext,
mac,
]);
return base64.encode(payload);
}
/// NIP-44 v2 decrypt.
///
/// Takes base64-encoded payload, returns plaintext string.
String nip44Decrypt(Uint8List conversationKey, String payloadBase64) {
final payload = base64.decode(payloadBase64);
// Minimum: version(1) + nonce(32) + min_ciphertext(32) + mac(32) = 97
if (payload.length < 97) {
throw FormatException('NIP-44 payload too short: ${payload.length}');
}
if (payload[0] != 0x02) {
throw FormatException('NIP-44 unsupported version: ${payload[0]}');
}
final nonce = Uint8List.sublistView(payload, 1, 33);
final ciphertext = Uint8List.sublistView(payload, 33, payload.length - 32);
final receivedMac = Uint8List.sublistView(payload, payload.length - 32);
final messageKeys = hkdfExpand(conversationKey, nonce, 76);
final chachaKey = Uint8List.sublistView(messageKeys, 0, 32);
final chachaNonce = Uint8List.sublistView(messageKeys, 32, 44);
final hmacKey = Uint8List.sublistView(messageKeys, 44, 76);
// Verify HMAC.
final expectedMac = _hmacSha256(hmacKey, _concat([nonce, ciphertext]));
if (!constantTimeEquals(receivedMac, expectedMac)) {
throw FormatException('NIP-44 HMAC verification failed');
}
final padded = _chacha20(chachaKey, chachaNonce, ciphertext);
return _unpad(padded);
}
/// Length of the base64 payload [nip44Encrypt] returns for a plaintext of
/// [plaintextBytes] UTF-8 bytes, or null when NIP-44 cannot encrypt it.
int? nip44PayloadLength(int plaintextBytes) {
if (plaintextBytes < 1 || plaintextBytes > 65535) return null;
// version(1) || nonce(32) || length(2) || padded || mac(32)
final payload = 1 + 32 + 2 + _calcPaddedLen(plaintextBytes) + 32;
return 4 * ((payload + 2) ~/ 3);
}
// ── Padding ─────────────────────────────────────────────────────────────────
int _calcPaddedLen(int unpaddedLen) {
if (unpaddedLen <= 0) throw ArgumentError('Length must be > 0');
if (unpaddedLen <= 32) return 32;
final nextPower = 1 << (_log2(unpaddedLen - 1) + 1);
final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8;
return chunk * (((unpaddedLen - 1) ~/ chunk) + 1);
}
int _log2(int x) {
var result = 0;
while ((1 << (result + 1)) <= x) {
result++;
}
return result;
}
Uint8List _pad(Uint8List plaintext) {
final len = plaintext.length;
final paddedLen = _calcPaddedLen(len);
// Result: [2-byte BE length][plaintext][zero padding]
final result = Uint8List(2 + paddedLen);
result[0] = (len >> 8) & 0xFF;
result[1] = len & 0xFF;
result.setRange(2, 2 + len, plaintext);
return result;
}
String _unpad(Uint8List padded) {
if (padded.length < 2) throw FormatException('Padded data too short');
final len = (padded[0] << 8) | padded[1];
if (len == 0 || 2 + len > padded.length) {
throw FormatException('Invalid padding length: $len');
}
return utf8.decode(padded.sublist(2, 2 + len));
}
// ── ChaCha20 (IETF, 12-byte nonce) ─────────────────────────────────────────
Uint8List _chacha20(Uint8List key, Uint8List nonce, Uint8List data) {
final cipher = ChaCha7539Engine();
cipher.init(false, ParametersWithIV(KeyParameter(key), nonce));
return cipher.process(data);
}
// ── HMAC-SHA256 ─────────────────────────────────────────────────────────────
Uint8List _hmacSha256(Uint8List key, Uint8List data) {
final hmac = HMac(SHA256Digest(), 64);
hmac.init(KeyParameter(key));
hmac.update(data, 0, data.length);
final out = Uint8List(32);
hmac.doFinal(out, 0);
return out;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
Uint8List _concat(List<Uint8List> parts) {
final totalLen = parts.fold<int>(0, (sum, p) => sum + p.length);
final result = Uint8List(totalLen);
var offset = 0;
for (final part in parts) {
result.setRange(offset, offset + part.length, part);
offset += part.length;
}
return result;
}
/// Constant-time byte comparison to prevent timing side-channels.
bool constantTimeEquals(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result == 0;
}