file upload actor
This commit is contained in:
parent
fca846eb43
commit
f55083ea1b
6 changed files with 682 additions and 0 deletions
174
lib/src/file_upload_actor.dart
Normal file
174
lib/src/file_upload_actor.dart
Normal 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
131
lib/src/nip44.dart
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue