file upload actor

This commit is contained in:
randogoth 2026-06-08 11:08:00 +03:00
parent fca846eb43
commit f55083ea1b
6 changed files with 682 additions and 0 deletions

View file

@ -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<FileUploadMessage, FileUploadResult> {
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<FileUploadResult> handle(FileUploadMessage message) => switch (message) {
RegisterForUpload() => _register(),
UploadFile(:final bytes, :final contentType) =>
_upload(bytes, contentType),
};
@override
Future<void> close() async => _httpClient.close();
Future<UploaderRegistered> _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<FileUploaded> _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 <base64(utf8(json(event)))>
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<String, dynamic>;
return (body['upload_url'] as String, body['cdn_url'] as String);
}
String _signEvent({
required int kind,
required List<List<String>> 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,
});
}
}

131
lib/src/nip44.dart Normal file
View file

@ -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<int> salt, List<int> info, int length) {
final prk = Hmac(sha256, salt).convert(ikm).bytes;
final output = <int>[];
var t = <int>[];
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<int> 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<int> a, List<int> 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;
}