From f55083ea1ba39e877e4efe236d5d59444499290c Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 8 Jun 2026 11:08:00 +0300 Subject: [PATCH] file upload actor --- lib/src/file_upload_actor.dart | 174 +++++++++++++ lib/src/nip44.dart | 131 ++++++++++ lib/swarm.dart | 1 + pubspec.yaml | 2 + test/file_upload_actor_test.dart | 323 +++++++++++++++++++++++++ test/file_upload_integration_test.dart | 51 ++++ 6 files changed, 682 insertions(+) create mode 100644 lib/src/file_upload_actor.dart create mode 100644 lib/src/nip44.dart create mode 100644 test/file_upload_actor_test.dart create mode 100644 test/file_upload_integration_test.dart diff --git a/lib/src/file_upload_actor.dart b/lib/src/file_upload_actor.dart new file mode 100644 index 0000000..7df48fb --- /dev/null +++ b/lib/src/file_upload_actor.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:actors/actors.dart'; +import 'package:bip340/bip340.dart' as bip340; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import 'nip44.dart'; +import 'relay_connection.dart'; + +const _kServicePubkey = + '7de7d9f8c0aad59b0e325eef5e1c7d777a3b4c1b3df8ac71c72dae763a161435'; +const _kDefaultApiBaseUrl = 'https://upload.otherwhere.app'; +const _kDefaultRelayUrl = 'wss://relay.otherwhere.app/'; +const _kMaxFileBytes = 10 * 1024 * 1024; // 10 MB server-side limit + +sealed class FileUploadMessage { + const FileUploadMessage(); +} + +final class RegisterForUpload extends FileUploadMessage { + const RegisterForUpload(); +} + +final class UploadFile extends FileUploadMessage { + final Uint8List bytes; + final String contentType; + const UploadFile({required this.bytes, required this.contentType}); +} + +sealed class FileUploadResult { + const FileUploadResult(); +} + +final class UploaderRegistered extends FileUploadResult { + const UploaderRegistered(); +} + +final class FileUploaded extends FileUploadResult { + final String cdnUrl; + const FileUploaded(this.cdnUrl); +} + +class FileUploadActor with Handler { + final String _privateKey; + final String _publicKey; + final String _appName; + final String _appSecret; + final String _apiBaseUrl; + final String _relayUrl; + final http.Client _httpClient; + final RelayConnection Function(String) _connectionFactory; + + FileUploadActor({ + required String privateKey, + required String appName, + required String appSecret, + String apiBaseUrl = _kDefaultApiBaseUrl, + String relayUrl = _kDefaultRelayUrl, + http.Client? httpClient, + RelayConnection Function(String)? connectionFactory, + }) : _privateKey = privateKey, + _publicKey = bip340.getPublicKey(privateKey), + _appName = appName, + _appSecret = appSecret, + _apiBaseUrl = apiBaseUrl, + _relayUrl = relayUrl, + _httpClient = httpClient ?? http.Client(), + _connectionFactory = connectionFactory ?? WebSocketRelayConnection.new; + + @override + Future handle(FileUploadMessage message) => switch (message) { + RegisterForUpload() => _register(), + UploadFile(:final bytes, :final contentType) => + _upload(bytes, contentType), + }; + + @override + Future close() async => _httpClient.close(); + + Future _register() async { + final encryptedSecret = + nip44Encrypt(_appSecret, _privateKey, _kServicePubkey); + final eventJson = _signEvent( + kind: 5392, + tags: [ + ['p', _kServicePubkey], + ['app', _appName], + ], + content: encryptedSecret, + ); + final conn = _connectionFactory(_relayUrl); + try { + await conn.connect(); + await conn.send(jsonEncode(['EVENT', jsonDecode(eventJson)])); + } finally { + await conn.close(); + } + return const UploaderRegistered(); + } + + Future _upload(Uint8List bytes, String contentType) async { + if (bytes.length > _kMaxFileBytes) { + throw Exception( + 'File exceeds 10 MB limit (${bytes.length} bytes)', + ); + } + final (uploadUrl, cdnUrl) = await _requestUploadUrl(contentType); + final putResponse = await _httpClient.put( + Uri.parse(uploadUrl), + headers: {'Content-Type': contentType}, + body: bytes, + ); + if (putResponse.statusCode != 200 && putResponse.statusCode != 204) { + throw Exception('Storage PUT failed: ${putResponse.statusCode}'); + } + return FileUploaded(cdnUrl); + } + + Future<(String, String)> _requestUploadUrl(String contentType) async { + final url = '$_apiBaseUrl/upload/request'; + final authEvent = _signEvent( + kind: 27235, + tags: [ + ['u', url], + ['method', 'POST'], + ], + content: '', + ); + // NIP-98: Authorization: Nostr + final token = base64.encode(utf8.encode(authEvent)); + final response = await _httpClient.post( + Uri.parse(url), + headers: { + 'Authorization': 'Nostr $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode({'app': _appName, 'content_type': contentType}), + ); + if (response.statusCode != 200) { + throw Exception('Upload request failed: ${response.statusCode}'); + } + final body = jsonDecode(response.body) as Map; + return (body['upload_url'] as String, body['cdn_url'] as String); + } + + String _signEvent({ + required int kind, + required List> tags, + required String content, + }) { + final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final ser = jsonEncode([0, _publicKey, ts, kind, tags, content]); + final idBytes = sha256.convert(utf8.encode(ser)).bytes; + final id = + idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final rng = Random.secure(); + final aux = List.generate(32, (_) => rng.nextInt(256)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final sig = bip340.sign(_privateKey, id, aux); + return jsonEncode({ + 'id': id, + 'pubkey': _publicKey, + 'created_at': ts, + 'kind': kind, + 'tags': tags, + 'content': content, + 'sig': sig, + }); + } +} diff --git a/lib/src/nip44.dart b/lib/src/nip44.dart new file mode 100644 index 0000000..c519924 --- /dev/null +++ b/lib/src/nip44.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; + +/// Derives the NIP-44 v2 conversation key. +/// secp256k1 ECDH(privkey, pubkey) → HKDF-SHA256(ikm=sharedX, salt=32·0x00, info="nip44-v2") → 32 bytes. +Uint8List nip44ConversationKey(String privkeyHex, String pubkeyHex) { + final domain = ECCurve_secp256k1(); + final priv = BigInt.parse(privkeyHex, radix: 16); + // Nostr pubkeys are x-only; always reconstruct with even-y (0x02 prefix). + final pubBytes = Uint8List.fromList([0x02, ...hexToBytes(pubkeyHex)]); + final pub = domain.curve.decodePoint(pubBytes)!; + final sharedX = (pub * priv)! + .x! + .toBigInteger()! + .toRadixString(16) + .padLeft(64, '0'); + return hkdf(hexToBytes(sharedX), Uint8List(32), utf8.encode('nip44-v2'), 32); +} + +/// Encrypts [plaintext] with NIP-44 v2. Returns a base64 payload. +String nip44Encrypt(String plaintext, String privkeyHex, String pubkeyHex) { + final convKey = nip44ConversationKey(privkeyHex, pubkeyHex); + + final nonce = Uint8List(32); + final rng = Random.secure(); + for (var i = 0; i < 32; i++) nonce[i] = rng.nextInt(256); + + final keys = hkdf(convKey, nonce, [], 76); + final chachaKey = keys.sublist(0, 32); + final chachaNonce = keys.sublist(32, 44); // 12 bytes — RFC 7539 + final hmacKey = keys.sublist(44, 76); + + final padded = _pad(utf8.encode(plaintext)); + + final chacha = ChaCha7539Engine() + ..init(true, ParametersWithIV(KeyParameter(chachaKey), chachaNonce)); + final ciphertext = Uint8List(padded.length); + chacha.processBytes(padded, 0, padded.length, ciphertext, 0); + + final mac = + Hmac(sha256, hmacKey).convert([...nonce, ...ciphertext]).bytes; + + return base64.encode( + Uint8List.fromList([0x02, ...nonce, ...ciphertext, ...mac]), + ); +} + +/// Decrypts a NIP-44 v2 base64 [payload]. Throws on HMAC failure or unknown version. +String nip44Decrypt(String payload, String privkeyHex, String pubkeyHex) { + final bytes = base64.decode(payload); + if (bytes[0] != 0x02) { + throw Exception('Unsupported NIP-44 version: ${bytes[0]}'); + } + final nonce = bytes.sublist(1, 33); + final mac = bytes.sublist(bytes.length - 32); + final ciphertext = bytes.sublist(33, bytes.length - 32); + + final convKey = nip44ConversationKey(privkeyHex, pubkeyHex); + final keys = hkdf(convKey, nonce, [], 76); + final chachaKey = keys.sublist(0, 32); + final chachaNonce = keys.sublist(32, 44); + final hmacKey = keys.sublist(44, 76); + + final expectedMac = + Hmac(sha256, hmacKey).convert([...nonce, ...ciphertext]).bytes; + if (!_constantTimeEqual(mac, expectedMac)) { + throw Exception('NIP-44 HMAC verification failed'); + } + + final chacha = ChaCha7539Engine() + ..init(true, ParametersWithIV(KeyParameter(chachaKey), chachaNonce)); + final plainPadded = Uint8List(ciphertext.length); + chacha.processBytes(ciphertext, 0, ciphertext.length, plainPadded, 0); + + return utf8.decode(_unpad(plainPadded)); +} + +/// Minimal HKDF-SHA256: extract then expand. +Uint8List hkdf(Uint8List ikm, List salt, List info, int length) { + final prk = Hmac(sha256, salt).convert(ikm).bytes; + final output = []; + var t = []; + var counter = 1; + while (output.length < length) { + t = Hmac(sha256, prk).convert([...t, ...info, counter]).bytes; + output.addAll(t); + counter++; + } + return Uint8List.fromList(output.sublist(0, length)); +} + +/// Decodes a hex string to bytes. +Uint8List hexToBytes(String hex) { + final result = Uint8List(hex.length ~/ 2); + for (var i = 0; i < result.length; i++) { + result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return result; +} + +int _calcPaddedLen(int n) { + if (n <= 32) return 32; + final nextPow = 1 << (n - 1).toRadixString(2).length; + final chunk = nextPow <= 256 ? 32 : nextPow ~/ 8; + return chunk * ((n + chunk - 1) ~/ chunk); +} + +Uint8List _pad(List plaintext) { + final n = plaintext.length; + final padded = Uint8List(_calcPaddedLen(n) + 2); + padded[0] = (n >> 8) & 0xff; + padded[1] = n & 0xff; + padded.setRange(2, 2 + n, plaintext); + return padded; +} + +Uint8List _unpad(Uint8List padded) { + final n = (padded[0] << 8) | padded[1]; + return padded.sublist(2, 2 + n); +} + +bool _constantTimeEqual(List a, List 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; +} diff --git a/lib/swarm.dart b/lib/swarm.dart index dde7e38..35def4a 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -4,3 +4,4 @@ export 'src/nostr_publish_actor.dart'; export 'src/nostr_fetch_actor.dart'; export 'src/nostr_signer_actor.dart'; export 'src/geo_actor.dart'; +export 'src/file_upload_actor.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 4068f1f..d80a1b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: bip340: ^0.3.1 crypto: ^3.0.7 dart_geohash: ^2.1.0 + http: ^1.6.0 + pointycastle: ^4.0.0 retry: ^3.1.2 web_socket_channel: ^3.0.3 diff --git a/test/file_upload_actor_test.dart b/test/file_upload_actor_test.dart new file mode 100644 index 0000000..2ad09bd --- /dev/null +++ b/test/file_upload_actor_test.dart @@ -0,0 +1,323 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:swarm/src/nip44.dart'; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +import 'fake_relay_connection.dart'; + +// Two well-known secp256k1 key pairs (k=1 and k=2). +const _sec1 = + '0000000000000000000000000000000000000000000000000000000000000001'; +const _sec2 = + '0000000000000000000000000000000000000000000000000000000000000002'; + +const _kServicePubkey = + '7de7d9f8c0aad59b0e325eef5e1c7d777a3b4c1b3df8ac71c72dae763a161435'; +const _kAppName = 'test-app'; +const _kAppSecret = 'test-secret-1234'; +const _kUploadUrl = 'https://storage.example.com/presigned/object'; +const _kCdnUrl = 'https://cdn.otherwhere.app/test/pubkey/uuid.jpg'; + +void main() { + final pub1 = bip340.getPublicKey(_sec1); + final pub2 = bip340.getPublicKey(_sec2); + + // --------------------------------------------------------------------------- + group('NIP-44', () { + test('conversation key is symmetric', () { + final ck1 = nip44ConversationKey(_sec1, pub2); + final ck2 = nip44ConversationKey(_sec2, pub1); + expect(ck1, equals(ck2)); + }); + + test('encrypt/decrypt round-trip (sec1 → pub2)', () { + const plaintext = 'hello upload service'; + final payload = nip44Encrypt(plaintext, _sec1, pub2); + expect(nip44Decrypt(payload, _sec2, pub1), equals(plaintext)); + }); + + test('encrypt/decrypt round-trip (sec2 → pub1)', () { + const plaintext = 'symmetric direction test'; + final payload = nip44Encrypt(plaintext, _sec2, pub1); + expect(nip44Decrypt(payload, _sec1, pub2), equals(plaintext)); + }); + + test('tampered MAC throws', () { + final payload = nip44Encrypt('tamper me', _sec1, pub2); + final bytes = base64.decode(payload); + bytes[bytes.length - 1] ^= 0xff; + expect( + () => nip44Decrypt(base64.encode(bytes), _sec2, pub1), + throwsException, + ); + }); + + test('unknown version byte throws', () { + final payload = nip44Encrypt('v check', _sec1, pub2); + final bytes = base64.decode(payload); + bytes[0] = 0x01; + expect( + () => nip44Decrypt(base64.encode(bytes), _sec2, pub1), + throwsException, + ); + }); + }); + + // --------------------------------------------------------------------------- + group('FileUploadActor — registration', () { + late FakeRelayConnection fakeRelay; + late FileUploadActor actor; + + setUp(() { + fakeRelay = FakeRelayConnection()..autoRespond = false; + actor = FileUploadActor( + privateKey: _sec1, + appName: _kAppName, + appSecret: _kAppSecret, + relayUrl: 'ws://test-relay', + connectionFactory: (_) => fakeRelay, + ); + }); + + tearDown(() => fakeRelay.dispose()); + + test('returns UploaderRegistered', () async { + expect( + await actor.handle(const RegisterForUpload()), + isA(), + ); + }); + + test('sends one EVENT to the relay', () async { + await actor.handle(const RegisterForUpload()); + final eventMsgs = fakeRelay.sentMessages + .where((m) => (jsonDecode(m) as List)[0] == 'EVENT') + .toList(); + expect(eventMsgs, hasLength(1)); + }); + + test('event kind is 5392', () async { + await actor.handle(const RegisterForUpload()); + final event = _extractEvent(fakeRelay); + expect(event['kind'], equals(5392)); + }); + + test('event has correct p and app tags', () async { + await actor.handle(const RegisterForUpload()); + final tags = _tagsMap(_extractEvent(fakeRelay)); + expect(tags['p'], equals(_kServicePubkey)); + expect(tags['app'], equals(_kAppName)); + }); + + test('event signature verifies', () async { + await actor.handle(const RegisterForUpload()); + final event = _extractEvent(fakeRelay); + expect( + bip340.verify( + event['pubkey'] as String, + event['id'] as String, + event['sig'] as String, + ), + isTrue, + ); + }); + + test('content decrypts to app secret', () async { + await actor.handle(const RegisterForUpload()); + final content = _extractEvent(fakeRelay)['content'] as String; + // Decrypt using ECDH(userPriv, servicePub) — same as server-side + // ECDH(servicePriv, userPub) by symmetry. + expect( + nip44Decrypt(content, _sec1, _kServicePubkey), + equals(_kAppSecret), + ); + }); + }); + + // --------------------------------------------------------------------------- + group('FileUploadActor — upload', () { + FileUploadActor makeActor(http.Client client) => FileUploadActor( + privateKey: _sec1, + appName: _kAppName, + appSecret: _kAppSecret, + apiBaseUrl: 'https://api.test', + httpClient: client, + ); + + MockClient makeClient({ + int requestStatus = 200, + int putStatus = 200, + }) => + MockClient((request) async { + if (request.url.path == '/upload/request') { + if (requestStatus != 200) { + return http.Response('error', requestStatus); + } + return http.Response( + jsonEncode({ + 'upload_url': _kUploadUrl, + 'cdn_url': _kCdnUrl, + }), + 200, + ); + } + if (request.url.toString() == _kUploadUrl) { + return http.Response('', putStatus); + } + return http.Response('not found', 404); + }); + + test('returns FileUploaded with CDN url', () async { + final result = await makeActor(makeClient()).handle( + UploadFile(bytes: Uint8List.fromList([1, 2, 3]), contentType: 'image/jpeg'), + ); + expect(result, isA()); + expect((result as FileUploaded).cdnUrl, equals(_kCdnUrl)); + }); + + test('POST body contains app and content_type', () async { + http.Request? captured; + final client = MockClient((request) async { + if (request.url.path == '/upload/request') { + captured = request; + return http.Response( + jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}), + 200, + ); + } + return http.Response('', 200); + }); + await makeActor(client).handle( + UploadFile(bytes: Uint8List(1), contentType: 'image/png'), + ); + final body = jsonDecode(captured!.body) as Map; + expect(body['app'], equals(_kAppName)); + expect(body['content_type'], equals('image/png')); + }); + + test('NIP-98 Authorization token is valid signed kind-27235 event', () async { + http.Request? captured; + final client = MockClient((request) async { + if (request.url.path == '/upload/request') { + captured = request; + return http.Response( + jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}), + 200, + ); + } + return http.Response('', 200); + }); + await makeActor(client).handle( + UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), + ); + + final authHeader = captured!.headers['Authorization']!; + expect(authHeader, startsWith('Nostr ')); + final event = jsonDecode( + utf8.decode(base64.decode(authHeader.substring(6))), + ) as Map; + + expect(event['kind'], equals(27235)); + expect(event['content'], equals('')); + final tags = _tagsMap(event); + expect(tags['u'], equals('https://api.test/upload/request')); + expect(tags['method'], equals('POST')); + expect( + bip340.verify( + event['pubkey'] as String, + event['id'] as String, + event['sig'] as String, + ), + isTrue, + ); + }); + + test('PUT uses presigned URL with correct headers and body', () async { + http.Request? putRequest; + final client = MockClient((request) async { + if (request.url.path == '/upload/request') { + return http.Response( + jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}), + 200, + ); + } + putRequest = request; + return http.Response('', 200); + }); + final bytes = Uint8List.fromList([0xff, 0xd8, 0xff, 0xe0]); + await makeActor(client).handle( + UploadFile(bytes: bytes, contentType: 'image/jpeg'), + ); + expect(putRequest!.method, equals('PUT')); + expect(putRequest!.url.toString(), equals(_kUploadUrl)); + expect(putRequest!.headers['Content-Type'], equals('image/jpeg')); + expect(putRequest!.bodyBytes, equals(bytes)); + }); + + test('PUT 204 is treated as success', () async { + final result = await makeActor(makeClient(putStatus: 204)).handle( + UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), + ); + expect(result, isA()); + }); + + test('non-200 upload request throws', () async { + for (final status in [401, 403, 429]) { + await expectLater( + makeActor(makeClient(requestStatus: status)).handle( + UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), + ), + throwsA(isA()), + ); + } + }); + + test('failed PUT throws', () async { + await expectLater( + makeActor(makeClient(putStatus: 500)).handle( + UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), + ), + throwsA(isA()), + ); + }); + + test('file over 10 MB throws before making any request', () async { + var requestMade = false; + final client = MockClient((_) async { + requestMade = true; + return http.Response('', 200); + }); + await expectLater( + makeActor(client).handle( + UploadFile( + bytes: Uint8List(10 * 1024 * 1024 + 1), + contentType: 'image/jpeg', + ), + ), + throwsA(isA()), + ); + expect(requestMade, isFalse); + }); + }); +} + +// --------------------------------------------------------------------------- +// Helpers + +Map _extractEvent(FakeRelayConnection relay) { + final raw = relay.sentMessages + .firstWhere((m) => (jsonDecode(m) as List)[0] == 'EVENT'); + return (jsonDecode(raw) as List)[1] as Map; +} + +Map _tagsMap(Map event) => + Map.fromEntries( + (event['tags'] as List).map( + (t) => MapEntry((t as List)[0] as String, t[1] as String), + ), + ); diff --git a/test/file_upload_integration_test.dart b/test/file_upload_integration_test.dart new file mode 100644 index 0000000..cede0a9 --- /dev/null +++ b/test/file_upload_integration_test.dart @@ -0,0 +1,51 @@ +@Tags(['integration']) +library; + +import 'dart:typed_data'; + +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +// Well-known test key — obviously not a real user key. +const _privKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + +// Per-app credentials for the xfay app. +// ignore: avoid_hardcoded_credentials (test-only, not a user secret) +const _kAppName = 'xfay'; +const _kAppSecret = + 'REDACTED'; + +// Minimal valid JPEG (SOI marker only — enough for R2 to store). +final _kTestBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xD9]); +const _kContentType = 'image/jpeg'; + +void main() { + late FileUploadActor actor; + + setUp(() { + actor = FileUploadActor( + privateKey: _privKey, + appName: _kAppName, + appSecret: _kAppSecret, + ); + }); + + tearDown(() => actor.close()); + + test('register publishes kind 5392 to relay without error', () async { + expect( + await actor.handle(const RegisterForUpload()), + isA(), + ); + }); + + test('upload returns a CDN url under the xfay app path', () async { + final result = await actor.handle( + UploadFile(bytes: _kTestBytes, contentType: _kContentType), + ); + expect(result, isA()); + final url = (result as FileUploaded).cdnUrl; + expect(url, startsWith('https://cdn.otherwhere.app/xfay/')); + }); +}