mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
424 lines
14 KiB
Dart
424 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:app_links/app_links.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:pointycastle/digests/sha256.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
/// Non-secret release configuration; absence preserves OSS self-custody.
|
|
const enterpriseBuildConfig = String.fromEnvironment('BUZZ_BUILD_ENTERPRISE');
|
|
bool get enterpriseEnabled => enterpriseBuildConfig.isNotEmpty;
|
|
|
|
/// One app-owned identity, scoped to the fixed enterprise community. Never contains a Nostr private key.
|
|
class EnterpriseIdentity {
|
|
EnterpriseIdentity({
|
|
http.Client? client,
|
|
FlutterSecureStorage? storage,
|
|
String config = enterpriseBuildConfig,
|
|
}) : _configuration = config,
|
|
_client = client ?? http.Client(),
|
|
_storage = storage ?? const FlutterSecureStorage();
|
|
static final instance = EnterpriseIdentity();
|
|
final String _configuration;
|
|
final http.Client _client;
|
|
final FlutterSecureStorage _storage;
|
|
final revision = ValueNotifier<int>(0);
|
|
Map<String, dynamic>? _tokens;
|
|
Map<String, dynamic>? _identity;
|
|
Future<void>? _refreshing;
|
|
Future<void>? _restoring;
|
|
bool _loggingIn = false;
|
|
int _generation = 0;
|
|
bool get authenticated => _tokens != null && _identity != null;
|
|
String? get pubkey => _identity?['pubkey'] as String?;
|
|
String? get relayUrl => _identity?['relayHttpUrl'] as String?;
|
|
Map<String, dynamic> get _config {
|
|
final config = jsonDecode(_configuration) as Map<String, dynamic>;
|
|
for (final key in ['signerUrl', 'issuer']) {
|
|
final url = Uri.parse(config[key] as String);
|
|
if (url.scheme != 'https' ||
|
|
url.host.isEmpty ||
|
|
url.userInfo.isNotEmpty ||
|
|
url.hasQuery ||
|
|
url.hasFragment ||
|
|
(key == 'issuer' && url.path != '' && url.path != '/')) {
|
|
throw StateError('Invalid enterprise build endpoint');
|
|
}
|
|
}
|
|
for (final key in [
|
|
'clientId',
|
|
'audience',
|
|
'organization',
|
|
'connection',
|
|
'redirectUri',
|
|
]) {
|
|
if (config[key] is! String || (config[key] as String).isEmpty) {
|
|
throw StateError('Incomplete enterprise build configuration');
|
|
}
|
|
}
|
|
final redirect = Uri.parse(config['redirectUri'] as String);
|
|
if (redirect.scheme != 'buzz' ||
|
|
redirect.host != 'enterprise-login' ||
|
|
redirect.hasQuery ||
|
|
redirect.hasFragment) {
|
|
throw StateError('Invalid enterprise callback');
|
|
}
|
|
return config;
|
|
}
|
|
|
|
String _endpoint(String key, String path) =>
|
|
'${(_config[key] as String).replaceFirst(RegExp(r'/+$'), '')}/$path';
|
|
|
|
Future<Map<String, dynamic>> _post(
|
|
String url,
|
|
Map<String, dynamic> body, {
|
|
String? token,
|
|
}) async {
|
|
final abort = Completer<void>();
|
|
final request = http.AbortableRequest(
|
|
'POST',
|
|
Uri.parse(url),
|
|
abortTrigger: abort.future,
|
|
)..followRedirects = false;
|
|
request.headers.addAll({
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
if (token != null) 'Authorization': 'Bearer $token',
|
|
});
|
|
request.body = jsonEncode(body);
|
|
return (() async {
|
|
final response = await _client.send(request);
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
await response.stream.listen((_) {}).cancel();
|
|
throw StateError(
|
|
'Corporate request rejected (${response.statusCode}); sign in again',
|
|
);
|
|
}
|
|
final bytes = BytesBuilder(copy: false);
|
|
await for (final chunk in response.stream) {
|
|
if (bytes.length + chunk.length > 256 * 1024) {
|
|
throw StateError('Corporate response too large');
|
|
}
|
|
bytes.add(chunk);
|
|
}
|
|
return jsonDecode(utf8.decode(bytes.takeBytes())) as Map<String, dynamic>;
|
|
})().timeout(const Duration(seconds: 15)).whenComplete(() {
|
|
if (!abort.isCompleted) abort.complete();
|
|
});
|
|
}
|
|
|
|
void _validateTokens(Map<String, dynamic> tokens) {
|
|
if (tokens['access_token'] is! String ||
|
|
(tokens['access_token'] as String).isEmpty ||
|
|
(tokens['access_token'] as String).length > 16384 ||
|
|
tokens['token_type']?.toString().toLowerCase() != 'bearer' ||
|
|
tokens['expires_in'] is! int ||
|
|
tokens['expires_in'] < 1 ||
|
|
tokens['expires_in'] > 300) {
|
|
throw StateError('Invalid corporate credentials');
|
|
}
|
|
final refresh = tokens['refresh_token'];
|
|
if (refresh != null &&
|
|
(refresh is! String || refresh.isEmpty || refresh.length > 16384)) {
|
|
throw StateError('Invalid corporate refresh credential');
|
|
}
|
|
tokens['expiresAt'] =
|
|
DateTime.now().millisecondsSinceEpoch ~/ 1000 +
|
|
(tokens['expires_in'] as int);
|
|
}
|
|
|
|
Map<String, dynamic> _validateIdentity(Map<String, dynamic> identity) {
|
|
if (identity['pubkey'] is! String ||
|
|
!RegExp(r'^[0-9a-f]{64}$').hasMatch(identity['pubkey'] as String)) {
|
|
throw StateError('Invalid corporate identity');
|
|
}
|
|
final relay = Uri.parse(identity['relayHttpUrl'] as String);
|
|
if (relay.scheme != 'https' ||
|
|
relay.host.isEmpty ||
|
|
relay.userInfo.isNotEmpty ||
|
|
relay.hasQuery ||
|
|
relay.hasFragment ||
|
|
(relay.path != '' && relay.path != '/') ||
|
|
identity['relayWsUrl'] != 'wss://${relay.authority}') {
|
|
throw StateError('Invalid corporate community');
|
|
}
|
|
return identity;
|
|
}
|
|
|
|
Future<void> restore() =>
|
|
_restoring ??= _restore().whenComplete(() => _restoring = null);
|
|
|
|
Future<void> _restore() async {
|
|
if (_configuration.isEmpty || _identity != null) return;
|
|
final generation = _generation;
|
|
final encoded = await _storage.read(key: 'buzz.enterprise.session');
|
|
if (encoded == null || generation != _generation) return;
|
|
final saved = jsonDecode(encoded) as Map<String, dynamic>;
|
|
// Credentials never cross build-selected issuer/client/signer boundaries.
|
|
if (saved['config'] != _configuration) {
|
|
throw StateError('Corporate build changed; sign in again');
|
|
}
|
|
_identity = _validateIdentity(
|
|
Map<String, dynamic>.from(saved['identity'] as Map),
|
|
);
|
|
_tokens = Map<String, dynamic>.from(saved['tokens'] as Map);
|
|
if (_tokens?['access_token'] is! String || _tokens?['expiresAt'] is! int) {
|
|
_tokens = null;
|
|
_identity = null;
|
|
throw StateError('Invalid saved corporate credentials');
|
|
}
|
|
try {
|
|
await accessToken();
|
|
} catch (_) {
|
|
_identity = null;
|
|
_tokens = null;
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _persist(
|
|
Map<String, dynamic> tokens,
|
|
Map<String, dynamic> identity,
|
|
) => _storage.write(
|
|
key: 'buzz.enterprise.session',
|
|
value: jsonEncode({
|
|
'config': _configuration,
|
|
'tokens': tokens,
|
|
'identity': identity,
|
|
}),
|
|
);
|
|
|
|
Future<String> accessToken() async {
|
|
final tokens = _tokens;
|
|
if (tokens == null) throw StateError('Corporate login required');
|
|
if ((tokens['expiresAt'] as int) <=
|
|
DateTime.now().millisecondsSinceEpoch ~/ 1000 + 30) {
|
|
_refreshing ??= _refresh().whenComplete(() => _refreshing = null);
|
|
await _refreshing;
|
|
}
|
|
return _tokens?['access_token'] as String? ??
|
|
(throw StateError('Corporate login required'));
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
final generation = _generation;
|
|
try {
|
|
final refresh = _tokens?['refresh_token'] as String?;
|
|
if (refresh == null) {
|
|
throw StateError('Corporate session expired; sign in again');
|
|
}
|
|
await _storage.delete(key: 'buzz.enterprise.session');
|
|
final next = await _post(_endpoint('issuer', 'oauth/token'), {
|
|
'grant_type': 'refresh_token',
|
|
'client_id': _config['clientId'],
|
|
'refresh_token': refresh,
|
|
});
|
|
_validateTokens(next);
|
|
final identity = _validateIdentity(
|
|
await _post(
|
|
_endpoint('signerUrl', 'v1/buzz/enterprise-signer/session'),
|
|
{},
|
|
token: next['access_token'] as String,
|
|
),
|
|
);
|
|
if (generation != _generation ||
|
|
identity['pubkey'] != pubkey ||
|
|
identity['relayHttpUrl'] != relayUrl) {
|
|
throw StateError('Corporate account changed');
|
|
}
|
|
await _persist(next, identity);
|
|
if (generation != _generation) {
|
|
throw StateError('Corporate account changed');
|
|
}
|
|
_tokens = next;
|
|
} catch (_) {
|
|
if (generation == _generation) {
|
|
_tokens = null;
|
|
revision.value++;
|
|
await _storage.delete(key: 'buzz.enterprise.session');
|
|
}
|
|
rethrow; // Never retry a potentially consumed rotating refresh token automatically.
|
|
}
|
|
}
|
|
|
|
Future<void> login() async {
|
|
if (_loggingIn) throw StateError('Corporate login already in progress');
|
|
_loggingIn = true;
|
|
// Drain any rotating refresh before a new login can replace the same secure-storage record.
|
|
try {
|
|
await _refreshing;
|
|
} catch (_) {
|
|
/* A new browser login is the recovery path. */
|
|
}
|
|
final generation = ++_generation;
|
|
StreamSubscription<Uri>? subscription;
|
|
try {
|
|
final config = _config;
|
|
final random = Random.secure();
|
|
String randomToken() => base64Url
|
|
.encode(List.generate(32, (_) => random.nextInt(256)))
|
|
.replaceAll('=', '');
|
|
final verifier = randomToken(), state = randomToken();
|
|
final challenge = base64Url
|
|
.encode(
|
|
SHA256Digest().process(Uint8List.fromList(ascii.encode(verifier))),
|
|
)
|
|
.replaceAll('=', '');
|
|
final redirect = Uri.parse(config['redirectUri'] as String);
|
|
final callback = Completer<Uri>();
|
|
subscription = AppLinks().uriLinkStream.listen((uri) {
|
|
if (uri.scheme != redirect.scheme ||
|
|
uri.host != redirect.host ||
|
|
uri.path != redirect.path ||
|
|
uri.hasFragment) {
|
|
return;
|
|
}
|
|
if (uri.queryParametersAll['state']?.length != 1 ||
|
|
uri.queryParameters['state'] != state ||
|
|
uri.queryParametersAll['code']?.length != 1 ||
|
|
uri.queryParameters.containsKey('error')) {
|
|
return;
|
|
}
|
|
if (!callback.isCompleted) callback.complete(uri);
|
|
});
|
|
final authorize = Uri.parse(_endpoint('issuer', 'authorize')).replace(
|
|
queryParameters: {
|
|
'response_type': 'code',
|
|
'client_id': config['clientId'],
|
|
'redirect_uri': redirect.toString(),
|
|
'audience': config['audience'],
|
|
'organization': config['organization'],
|
|
'connection': config['connection'],
|
|
'scope': 'openid profile email offline_access',
|
|
'state': state,
|
|
'code_challenge_method': 'S256',
|
|
'code_challenge': challenge,
|
|
},
|
|
);
|
|
if (!await launchUrl(authorize, mode: LaunchMode.externalApplication)) {
|
|
throw StateError('Cannot open corporate login');
|
|
}
|
|
final uri = await callback.future.timeout(const Duration(minutes: 5));
|
|
final tokens = await _post(_endpoint('issuer', 'oauth/token'), {
|
|
'grant_type': 'authorization_code',
|
|
'client_id': config['clientId'],
|
|
'redirect_uri': redirect.toString(),
|
|
'code': uri.queryParameters['code'],
|
|
'code_verifier': verifier,
|
|
});
|
|
_validateTokens(tokens);
|
|
final identity = _validateIdentity(
|
|
await _post(
|
|
_endpoint('signerUrl', 'v1/buzz/enterprise-signer/session'),
|
|
{},
|
|
token: tokens['access_token'] as String,
|
|
),
|
|
);
|
|
if (generation != _generation) {
|
|
throw StateError('Corporate login changed');
|
|
}
|
|
await _persist(tokens, identity);
|
|
if (generation != _generation) {
|
|
await _storage.delete(key: 'buzz.enterprise.session');
|
|
throw StateError('Corporate login changed');
|
|
}
|
|
_tokens = tokens;
|
|
_identity = identity;
|
|
revision.value++;
|
|
} finally {
|
|
await subscription?.cancel();
|
|
_loggingIn = false;
|
|
}
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
_generation++;
|
|
try {
|
|
await _refreshing;
|
|
} catch (_) {
|
|
/* Still clear credentials below. */
|
|
}
|
|
_tokens = null;
|
|
_identity = null;
|
|
revision.value++;
|
|
await _storage.delete(key: 'buzz.enterprise.session');
|
|
}
|
|
|
|
Future<nostr.Event> sign({
|
|
required int kind,
|
|
required String content,
|
|
required List<List<String>> tags,
|
|
int? createdAt,
|
|
}) async {
|
|
final generation = _generation;
|
|
final expected = pubkey;
|
|
if (expected == null) throw StateError('Corporate login required');
|
|
final template = {
|
|
'kind': kind,
|
|
'content': content,
|
|
'tags': tags.map((t) => List<String>.of(t)).toList(),
|
|
'created_at': createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
};
|
|
final purpose = kind == 22242
|
|
? 'nip42-auth'
|
|
: kind == 27235
|
|
? 'http-auth'
|
|
: kind == 24242
|
|
? (tags.any((t) => t.length > 1 && t[0] == 't' && t[1] == 'upload')
|
|
? 'media-upload'
|
|
: 'media-read')
|
|
: 'publish';
|
|
final response = await _post(
|
|
_endpoint('signerUrl', 'v1/buzz/enterprise-signer/events/sign'),
|
|
{'purpose': purpose, 'event': template},
|
|
token: await accessToken(),
|
|
);
|
|
final event = nostr.Event.fromJson(
|
|
jsonEncode(response['event']),
|
|
); // Constructor verifies id and Schnorr signature.
|
|
if (generation != _generation ||
|
|
event.pubkey != expected ||
|
|
event.kind != kind ||
|
|
event.content != content ||
|
|
event.createdAt != template['created_at'] ||
|
|
jsonEncode(event.tags) != jsonEncode(template['tags'])) {
|
|
throw StateError('Corporate signature or identity mismatch');
|
|
}
|
|
return event;
|
|
}
|
|
}
|
|
|
|
/// Central signing seam. In enterprise builds nsec is ignored, never used as fallback.
|
|
Future<nostr.Event> signClientEvent({
|
|
required String? nsec,
|
|
required int kind,
|
|
required String content,
|
|
required List<List<String>> tags,
|
|
int? createdAt,
|
|
}) async {
|
|
if (enterpriseEnabled) {
|
|
return EnterpriseIdentity.instance.sign(
|
|
kind: kind,
|
|
content: content,
|
|
tags: tags,
|
|
createdAt: createdAt,
|
|
);
|
|
}
|
|
if (nsec == null || nsec.isEmpty) throw StateError('No signing identity');
|
|
final key = nostr.Nip19.decode(payload: nsec).data;
|
|
return nostr.Event.from(
|
|
kind: kind,
|
|
content: content,
|
|
tags: tags,
|
|
createdAt: createdAt,
|
|
secretKey: key,
|
|
verify: false,
|
|
);
|
|
}
|