Files
buzz/mobile/lib/shared/push/dev_push_lease.dart
Tom Brow 868e2f8f32 refactor(push): one app profile per gateway (#8206)
Buzz push currently selects an application profile, such as `dogfood`,
during client registration. We want each gateway to serve one Apple app
configured by the operator instead. This removes profile selection from
the client, relay, and gateway, while allowing internal and public
gateways to use their own Apple identities.

This PR implements that model in the Buzz source and upstream deployment
chart. Discovery, enrollment, grants, and leases no longer carry an
application-profile selector. Each gateway has one configured Apple
application and APNs transport.

The protocol changes are incompatible with the old registration format,
so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do
not discover the new push capability. Updated clients use separate
Keychain records and register again. The [push
notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md)
describe the experimental contract.

Landing this PR is the first step. Publish the matching gateway image
and upstream chart, then land the [shared chart
update](https://github.com/squareup/cash-helm-charts/pull/5740) and
[gateway deployment
update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085).
Before cutover, stop old gateway instances and check legacy registration
state. Migrations refuse to proceed if old gateway installations or
active relay leases remain; they do not delete that state.
Physical-device verification remains necessary before production
activation.

### Validation
Gateway and focused Flutter push tests pass, as do the Swift package,
simulator build, and native notification/enrichment tests. A local relay
advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The
PostgreSQL suite passed 868 tests; its two migration-test failures were
corrected and pass a focused rerun together with the gateway cutover
guard. Chart rendering and release-contract checks pass.

Desktop JavaScript tests, desktop and web builds, native desktop
compilation, mobile analysis/tests, and desktop native tests pass. The
full Rust unit lane and prescribed `just test` command pass, including
workspace integration tests against task-isolated local services. The
production overlay now requires an explicitly supplied APNs topic, with
omission and rendered-value regression coverage. The corrected Swift
checksum assertion passes the complete package suite: 78 XCTest tests
and 42 Swift Testing tests. No physical-device enrollment or production
cutover is claimed.

Local merge validation: the embedded migration test and formatting pass
after reserving profile migration 0060 behind rollback migration 0059.
The local pre-push Rust lane failed ACP timing/steering tests
(`idle_resets_on_stdout_activity`,
`keepalive_resets_idle_past_deadline`, and the failed/missing-outcome
steering tests). ACP and its dependency manifests are unchanged by this
branch; all four pass in an isolated single-thread rerun. The documented
local-check exception skips only the Rust pre-push lane for publication;
required CI remains mandatory. Mobile and desktop pre-push lanes passed.

Combined migration validation: after rollback 0059 landed, the main
merge preserves profile migration 0060 and both embedded migration
assertions. The focused migration test and formatting pass. The next
local push again failed only the unchanged ACP idle/keepalive timing
tests (1053 passed, 2 failed, 3 ignored); other lanes passed.
Publication uses the same documented local Rust-lane exception; required
CI remains mandatory.

Follow-up review fix: removed the legacy profile column from the
copied-lease fixture. All four event-follow-up contract tests pass on
isolated PostgreSQL databases, covering desired and migrated schemas in
dual and app-only modes. Formatting passes. Publication retains the
prior passing local-lane evidence and uses the authorized pre-push
exception for the known unrelated ACP timing failures; required CI
remains mandatory.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-09 15:09:53 -07:00

601 lines
18 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import '../crypto/nip44.dart';
import '../relay/nostr_models.dart';
import '../relay/signed_event_relay.dart';
import 'push_bridge.dart';
import 'push_subscription.dart';
const buzzPushLeaseKind = 30350;
const buzzPushTransport = 'apns';
const _maxSafeJsonInteger = 9007199254740991;
const _maxLeaseLifetimeSeconds = 2592000;
const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$';
const _installationIdPattern = r'^[0-9a-f]{32}$';
class BuzzPushLeaseDescriptor {
final String origin;
final String executorKeyId;
final String executorPubkey;
final String transport;
final int maxLeaseTtlSeconds;
final int maxContentLength;
final int maxPlaintextLength;
final int maxEndpointLength;
final int maxStringLength;
const BuzzPushLeaseDescriptor({
required this.origin,
required this.executorKeyId,
required this.executorPubkey,
required this.transport,
required this.maxLeaseTtlSeconds,
required this.maxContentLength,
required this.maxPlaintextLength,
required this.maxEndpointLength,
required this.maxStringLength,
});
factory BuzzPushLeaseDescriptor.fromRelayInformation(
Map<String, dynamic> information,
) {
final extensions = _stringList(
information['supported_extensions'],
name: 'supported_extensions',
);
if (!extensions.contains('buzz-push-v1')) {
throw const FormatException('NIP-11 does not advertise buzz-push-v1');
}
final push = _stringMap(information['push'], name: 'push');
_requireExactKeys(
push,
required: const {
'origin',
'keys',
'push_kinds',
'h_grammar',
'class_support',
'limitation',
},
allowed: const {
'origin',
'keys',
'push_kinds',
'h_grammar',
'class_support',
'limitation',
},
name: 'push descriptor',
);
final origin = _canonicalOrigin(push['origin']);
final keys = _mapList(push['keys'], name: 'push.keys');
final keyIds = <String>{};
final currentKeys = <Map<String, dynamic>>[];
for (final key in keys) {
_requireExactKeys(
key,
required: const {'id', 'pubkey'},
allowed: const {'id', 'pubkey', 'current', 'retiring'},
name: 'push key',
);
final id = _nonEmptyString(key['id'], name: 'push key id');
if (!keyIds.add(id)) {
throw FormatException('Duplicate push key id: $id');
}
_lowercaseHex64(key['pubkey'], name: 'push key pubkey');
final current = key['current'];
final retiring = key['retiring'];
if (current != null && current is! bool) {
throw const FormatException('push key current must be a boolean');
}
if (retiring != null && retiring is! bool) {
throw const FormatException('push key retiring must be a boolean');
}
if (current == true) currentKeys.add(key);
}
if (currentKeys.length != 1) {
throw const FormatException(
'push descriptor must contain exactly one current key',
);
}
final currentKey = currentKeys.single;
const transport = buzzPushTransport;
final pushKinds = _intList(push['push_kinds'], name: 'push_kinds');
if (!buzzPushEligibleKinds.every(pushKinds.contains)) {
throw const FormatException(
'NIP-11 does not advertise every Buzz message kind for push',
);
}
final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar');
if (hGrammar != 'uuid-v4-lowercase') {
throw const FormatException('Unsupported push h_grammar');
}
final classSupport = _stringMap(
push['class_support'],
name: 'class_support',
);
final supportedClasses = _stringList(
classSupport[buzzPushTransport],
name: 'class_support.apns',
);
const knownClasses = {'default'};
if (supportedClasses.any((value) => !knownClasses.contains(value))) {
throw const FormatException('class_support contains an unknown class');
}
if (!supportedClasses.contains('default')) {
throw const FormatException('APNs does not support the default class');
}
final limitation = _stringMap(push['limitation'], name: 'limitation');
_requireExactKeys(
limitation,
required: const {
'max_lease_ttl',
'max_leases_per_pubkey',
'max_subscriptions_per_lease',
'max_kinds',
'max_authors',
'max_h',
'max_tag_values',
'max_ignore',
'max_content_len',
'max_plaintext_len',
'max_endpoint_len',
'max_string_len',
},
allowed: const {
'max_lease_ttl',
'max_leases_per_pubkey',
'max_subscriptions_per_lease',
'max_kinds',
'max_authors',
'max_h',
'max_tag_values',
'max_ignore',
'max_content_len',
'max_plaintext_len',
'max_endpoint_len',
'max_string_len',
},
name: 'push limitation',
);
for (final entry in limitation.entries) {
_positiveInt(entry.value, name: 'limitation.${entry.key}');
}
final maxStringLength = limitation['max_string_len'] as int;
_checkStringLength(origin, maxStringLength, name: 'origin');
_checkStringLength(
currentKey['id'] as String,
maxStringLength,
name: 'push key id',
);
final maxLeaseTtl = limitation['max_lease_ttl'] as int;
if (maxLeaseTtl > _maxLeaseLifetimeSeconds) {
throw const FormatException('max_lease_ttl exceeds the NIP-PL v1 limit');
}
return BuzzPushLeaseDescriptor(
origin: origin,
executorKeyId: currentKey['id'] as String,
executorPubkey: currentKey['pubkey'] as String,
transport: transport,
maxLeaseTtlSeconds: maxLeaseTtl,
maxContentLength: limitation['max_content_len'] as int,
maxPlaintextLength: limitation['max_plaintext_len'] as int,
maxEndpointLength: limitation['max_endpoint_len'] as int,
maxStringLength: maxStringLength,
);
}
}
Future<BuzzPushLeaseDescriptor> fetchBuzzPushLeaseDescriptor(
String relayBaseUrl, {
http.Client? client,
Duration timeout = const Duration(seconds: 8),
}) async {
final uri = Uri.tryParse(relayBaseUrl);
if (uri == null ||
!const {'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty) {
throw FormatException('Invalid relay HTTP URL: $relayBaseUrl');
}
final requestUri = uri.resolve('/');
final ownedClient = client ?? http.Client();
try {
final response = await ownedClient
.get(requestUri, headers: const {'Accept': 'application/nostr+json'})
.timeout(timeout);
if (response.statusCode != 200) {
throw StateError(
'NIP-11 request failed with HTTP ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('NIP-11 response must be a JSON object');
}
return BuzzPushLeaseDescriptor.fromRelayInformation(decoded);
} finally {
if (client == null) ownedClient.close();
}
}
class BuzzPushLeasePublication {
final String eventId;
final int expiration;
final String plaintext;
const BuzzPushLeasePublication({
required this.eventId,
required this.expiration,
required this.plaintext,
});
}
typedef BuzzPushLeaseSubmit =
Future<NostrEvent> Function({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
});
Future<BuzzPushLeasePublication> publishBuzzDevPushLease({
required BuzzPushEndpointGrant grant,
String? leaseInstallationId,
int? leaseGeneration,
required BuzzPushLeaseDescriptor descriptor,
required String nsec,
required String memberPubkey,
required List<BuzzPushSubscription> subscriptions,
required BuzzPushLeaseSubmit submit,
DateTime Function() now = DateTime.now,
}) async {
_validateGrant(grant, descriptor);
final effectiveLeaseInstallationId =
leaseInstallationId ?? grant.installationId;
if (!RegExp(_installationIdPattern).hasMatch(effectiveLeaseInstallationId)) {
throw const FormatException(
'Lease installation id must be 16 random bytes encoded as lowercase hex',
);
}
final effectiveLeaseGeneration = leaseGeneration ?? grant.generation;
if (effectiveLeaseGeneration <= 0 ||
effectiveLeaseGeneration > _maxSafeJsonInteger) {
throw const FormatException('Lease generation is invalid');
}
final normalizedMemberPubkey = _lowercaseHex64(
memberPubkey,
name: 'member pubkey',
);
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) {
throw const FormatException('Signing key must be a 32-byte nsec');
}
final signingPubkey = nostr.Keys(decoded.data).public;
if (signingPubkey != normalizedMemberPubkey) {
throw const FormatException(
'Authenticated signing key does not match the lease member pubkey',
);
}
final nowSeconds = now().millisecondsSinceEpoch ~/ 1000;
final expiration = min(
grant.expiresAt,
nowSeconds + descriptor.maxLeaseTtlSeconds,
);
if (expiration <= nowSeconds) {
throw const FormatException('Endpoint grant is already expired');
}
final plaintextMap = <String, dynamic>{
'v': 1,
'origin': descriptor.origin,
'transport': descriptor.transport,
'endpoint': grant.endpointGrant,
'generation': effectiveLeaseGeneration,
'active': true,
'subscriptions': [
for (final subscription in subscriptions) subscription.toJson(),
],
};
final plaintext = jsonEncode(plaintextMap);
if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) {
throw const FormatException('lease plaintext exceeds the advertised limit');
}
final conversationKey = getConversationKey(
decoded.data,
descriptor.executorPubkey,
);
final content = nip44Encrypt(conversationKey, plaintext);
if (utf8.encode(content).length > descriptor.maxContentLength) {
throw const FormatException(
'lease ciphertext exceeds the advertised limit',
);
}
final acknowledged = await submit(
kind: buzzPushLeaseKind,
content: content,
tags: [
['d', effectiveLeaseInstallationId],
['expiration', '$expiration'],
['exec', descriptor.executorKeyId],
],
createdAt: nowSeconds,
);
if (acknowledged.id.isEmpty) {
throw StateError('Relay returned an empty event id for the push lease');
}
return BuzzPushLeasePublication(
eventId: acknowledged.id,
expiration: expiration,
plaintext: plaintext,
);
}
Future<BuzzPushLeasePublication> publishBuzzDevPushLeaseThroughRelay({
required BuzzPushEndpointGrant grant,
String? leaseInstallationId,
int? leaseGeneration,
required BuzzPushLeaseDescriptor descriptor,
required String nsec,
required String memberPubkey,
required List<BuzzPushSubscription> subscriptions,
required SignedEventRelay relay,
DateTime Function() now = DateTime.now,
}) => publishBuzzDevPushLease(
grant: grant,
leaseInstallationId: leaseInstallationId,
leaseGeneration: leaseGeneration,
descriptor: descriptor,
nsec: nsec,
memberPubkey: memberPubkey,
subscriptions: subscriptions,
submit: relay.submit,
now: now,
);
/// Publishes the minimal higher-generation inactive lease used when a
/// community is removed from this device. Gateway delegation is intentionally
/// untouched because it is scoped to the installation and relay key, not the
/// community.
Future<BuzzPushLeasePublication> publishBuzzPushLeaseTombstone({
required BuzzPushLeaseDescriptor descriptor,
required String installationId,
required int generation,
required String nsec,
required String memberPubkey,
required BuzzPushLeaseSubmit submit,
DateTime Function() now = DateTime.now,
}) async {
if (!RegExp(_installationIdPattern).hasMatch(installationId)) {
throw const FormatException(
'Installation id must be 16 random bytes encoded as lowercase hex',
);
}
if (generation <= 0 || generation > _maxSafeJsonInteger) {
throw const FormatException('Tombstone generation is invalid');
}
final normalizedMemberPubkey = _lowercaseHex64(
memberPubkey,
name: 'member pubkey',
);
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) {
throw const FormatException('Signing key must be a 32-byte nsec');
}
if (nostr.Keys(decoded.data).public != normalizedMemberPubkey) {
throw const FormatException(
'Authenticated signing key does not match the lease member pubkey',
);
}
final nowSeconds = now().millisecondsSinceEpoch ~/ 1000;
final expiration = nowSeconds + descriptor.maxLeaseTtlSeconds;
final plaintextMap = <String, dynamic>{
'v': 1,
'origin': descriptor.origin,
'generation': generation,
'active': false,
};
final plaintext = jsonEncode(plaintextMap);
if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) {
throw const FormatException('lease plaintext exceeds the advertised limit');
}
final content = nip44Encrypt(
getConversationKey(decoded.data, descriptor.executorPubkey),
plaintext,
);
if (utf8.encode(content).length > descriptor.maxContentLength) {
throw const FormatException(
'lease ciphertext exceeds the advertised limit',
);
}
final acknowledged = await submit(
kind: buzzPushLeaseKind,
content: content,
tags: [
['d', installationId],
['expiration', '$expiration'],
['exec', descriptor.executorKeyId],
],
createdAt: nowSeconds,
);
if (acknowledged.id.isEmpty) {
throw StateError('Relay returned an empty event id for the push tombstone');
}
return BuzzPushLeasePublication(
eventId: acknowledged.id,
expiration: expiration,
plaintext: plaintext,
);
}
Future<BuzzPushLeasePublication> publishBuzzPushLeaseTombstoneThroughRelay({
required BuzzPushLeaseDescriptor descriptor,
required String installationId,
required int generation,
required String nsec,
required String memberPubkey,
required SignedEventRelay relay,
DateTime Function() now = DateTime.now,
}) => publishBuzzPushLeaseTombstone(
descriptor: descriptor,
installationId: installationId,
generation: generation,
nsec: nsec,
memberPubkey: memberPubkey,
submit: relay.submit,
now: now,
);
void _validateGrant(
BuzzPushEndpointGrant grant,
BuzzPushLeaseDescriptor descriptor,
) {
if (utf8.encode(grant.installationId).length > 64 ||
!RegExp(_installationIdPattern).hasMatch(grant.installationId)) {
throw const FormatException(
'Installation id must be 16 random bytes encoded as lowercase hex',
);
}
if (grant.relayPubkey != descriptor.executorPubkey) {
throw const FormatException(
'Stored endpoint grant is delegated to a different relay key',
);
}
if (grant.endpointGrant.isEmpty ||
utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) {
throw const FormatException(
'Endpoint grant violates the advertised endpoint limit',
);
}
if (grant.endpointEpoch <= 0) {
throw const FormatException('Endpoint grant epoch is invalid');
}
if (grant.generation <= 0 || grant.generation > _maxSafeJsonInteger) {
throw const FormatException('Endpoint grant generation is invalid');
}
}
String _canonicalOrigin(Object? value) {
final origin = _nonEmptyString(value, name: 'origin');
final uri = Uri.tryParse(origin);
if (uri == null ||
!const {'ws', 'wss'}.contains(uri.scheme) ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.path.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment ||
'${uri.scheme}://${uri.authority}' != origin) {
throw FormatException('Invalid canonical push origin: $origin');
}
return origin;
}
void _checkStringLength(String value, int maximum, {required String name}) {
if (maximum <= 0 || utf8.encode(value).length > maximum) {
throw FormatException('$name exceeds its advertised byte limit');
}
}
String _lowercaseHex64(Object? value, {required String name}) {
final text = _nonEmptyString(value, name: name);
if (!RegExp(_lowercaseHex64Pattern).hasMatch(text)) {
throw FormatException('$name must be exactly 64 lowercase hex characters');
}
return text;
}
String _nonEmptyString(Object? value, {required String name}) {
if (value is! String || value.isEmpty) {
throw FormatException('$name must be a non-empty string');
}
return value;
}
int _positiveInt(Object? value, {required String name}) {
if (value is! int || value <= 0) {
throw FormatException('$name must be a positive integer');
}
return value;
}
Map<String, dynamic> _stringMap(Object? value, {required String name}) {
if (value is! Map<String, dynamic>) {
throw FormatException('$name must be an object');
}
return value;
}
List<Map<String, dynamic>> _mapList(Object? value, {required String name}) {
if (value is! List || value.isEmpty) {
throw FormatException('$name must be a non-empty array');
}
return [
for (final item in value)
if (item is Map<String, dynamic>)
item
else
throw FormatException('$name entries must be objects'),
];
}
List<String> _stringList(Object? value, {required String name}) {
if (value is! List || value.isEmpty) {
throw FormatException('$name must be a non-empty string array');
}
return [
for (final item in value)
if (item is String && item.isNotEmpty)
item
else
throw FormatException('$name entries must be non-empty strings'),
];
}
List<int> _intList(
Object? value, {
required String name,
bool allowEmpty = false,
}) {
if (value is! List || (!allowEmpty && value.isEmpty)) {
throw FormatException(
'$name must be ${allowEmpty ? 'an' : 'a non-empty'} integer array',
);
}
return [
for (final item in value)
if (item is int && item >= 0)
item
else
throw FormatException('$name entries must be non-negative integers'),
];
}
void _requireExactKeys(
Map<String, dynamic> value, {
required Set<String> required,
required Set<String> allowed,
required String name,
}) {
final missing = required.difference(value.keys.toSet());
if (missing.isNotEmpty) {
throw FormatException('$name is missing ${missing.join(', ')}');
}
final unexpected = value.keys.toSet().difference(allowed);
if (unexpected.isNotEmpty) {
throw FormatException('$name contains unknown field ${unexpected.first}');
}
}