diff --git a/.gitignore b/.gitignore index 65c34dc..f62a766 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Files and directories created by pub. .dart_tool/ .packages +*.swp +x +tags # Conventional directory for build outputs. build/ diff --git a/README.md b/README.md index a5989bc..23cdd37 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,15 @@ flutter pub add nostr ## [NIPS](https://github.com/nostr-protocol/nips) - [x] [NIP 01 Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) - [x] [NIP 02 Contact List and Petnames](https://github.com/nostr-protocol/nips/blob/master/02.md) +- [x] [NIP 04 Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) +- [x] [NIP 05 Mapping Nostr keys to DNS-based internet identifiers](https://github.com/nostr-protocol/nips/blob/master/05.md) +- [x] [NIP 10 Conventions for clients' use of e and p tags in text events](https://github.com/nostr-protocol/nips/blob/master/10.md) - [x] [NIP 15 End of Stored Events Notice](https://github.com/nostr-protocol/nips/blob/master/15.md) +- [x] [NIP 19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md) - [x] [NIP 20 Command Results](https://github.com/nostr-protocol/nips/blob/master/20.md) +- [x] [NIP 28 Public Chat](https://github.com/nostr-protocol/nips/blob/master/28.md) +- [x] [NIP 51 Lists](https://github.com/nostr-protocol/nips/blob/master/51.md) + ## Usage ### Events messages diff --git a/example/message_example.dart b/example/message_example.dart index 2321eab..d5df783 100644 --- a/example/message_example.dart +++ b/example/message_example.dart @@ -2,7 +2,7 @@ import 'package:nostr/nostr.dart'; void main() async { var eventPayload = - '["EVENT","3979053091133091",{"id":"a60679692533b308f1d862c2a5ca5c08a304e5157b1df5cde0ff0454b9920605","pubkey":"7c579328cf9028a4548d5117afa4f8448fb510ca9023f576b7bc90fc5be6ce7e","created_at":1674405882,"kind":1,"tags":[],"content":"GM gm gm! Currently bathing my brain in coffee ☕️ hahaha. How many other nostrinos love coffee? 🤪🤙","sig":"10262aa6a83e0b744cda2097f06f7354357512b82846f6ef23ef7d997136b64815c343b613a0635a27da7e628c96ac2475f66dd72513c1fb8ce6560824eb25b8"}]'; + '["EVENT","5ce1758166673a70e391303fb7cfeb0f5d47ec38a9342a27858950d13424d59b",{"content":"No quotes from the Bible? My global feed is full of religious nutcases","created_at":1685026912,"id":"e695c81fa5099b9f3ef0d868d8143eae481954114681bbe4432b50e44e199927","kind":1,"pubkey":"ab4103fc8cd4e1d8d31a99d079ed8293bdc26b11ec1ec61d95c13e43d7e048ff","sig":"0d17d6197ad12ab5ad77eb51231ae12c2ce1e639218bb6e3a01cce78aa092f3e77fb1f914b690675a425dcfd5b4dfa7be72c2cb608568798361781d75e354b32","tags":[["e","7804acd35bb9727d0374545a99bb4f30f901289aebaf3cf330dda28c235cd7ad"],["p","1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411"]]}]'; var event = Message.deserialize(eventPayload); assert(event.type == "EVENT"); assert(event.message.id == diff --git a/lib/nostr.dart b/lib/nostr.dart index bd86992..ece862d 100644 --- a/lib/nostr.dart +++ b/lib/nostr.dart @@ -11,3 +11,10 @@ export 'src/close.dart'; export 'src/message.dart'; export 'src/utils.dart'; export 'src/nips/nip_002.dart'; +export 'src/nips/nip_004.dart'; +export 'src/nips/nip_005.dart'; +export 'src/nips/nip_010.dart'; +export 'src/nips/nip_019.dart'; +export 'src/nips/nip_020.dart'; +export 'src/nips/nip_028.dart'; +export 'src/nips/nip_051.dart'; diff --git a/lib/src/close.dart b/lib/src/close.dart index fbbbe7a..5205226 100644 --- a/lib/src/close.dart +++ b/lib/src/close.dart @@ -17,7 +17,7 @@ class Close { /// Deserialize a nostr close message /// - ["CLOSE", subscription_id] Close.deserialize(input) { - assert(input.length == 2); + if (input.length != 2) throw 'Invalid length for CLOSE message'; subscriptionId = input[1]; } } diff --git a/lib/src/crypto/kepler.dart b/lib/src/crypto/kepler.dart new file mode 100644 index 0000000..df1c042 --- /dev/null +++ b/lib/src/crypto/kepler.dart @@ -0,0 +1,63 @@ +// credit: https://github.com/tjcampanella/kepler/blob/master/lib/src/kepler.dart + +import 'package:convert/convert.dart'; +import 'dart:typed_data'; +import 'package:pointycastle/export.dart'; +import 'operator.dart'; + +class Kepler { + /// return a Bytes data secret + static List> byteSecret(String privateString, String publicString) { + final secret = rawSecret(privateString, publicString); + assert(secret.x != null && secret.y != null); + final xs = secret.x!.toBigInteger()!.toRadixString(16); + final ys = secret.y!.toBigInteger()!.toRadixString(16); + final hexX = leftPadding(xs, 64); + final hexY = leftPadding(ys, 64); + final secretBytes = Uint8List.fromList(hex.decode('$hexX$hexY')); + return [secretBytes.sublist(0, 32), secretBytes.sublist(32, 40)]; + } + + /// return a ECPoint data secret + static ECPoint rawSecret(String privateString, String publicString) { + final privateKey = loadPrivateKey(privateString); + final publicKey = loadPublicKey(publicString); + assert(privateKey.d != null && publicKey.Q != null); + return scalarMultiple(privateKey.d!, publicKey.Q!); + } + + static String leftPadding(String s, int width) { + const paddingData = '000000000000000'; + final paddingWidth = width - s.length; + if (paddingWidth < 1) { + return s; + } + return "${paddingData.substring(0, paddingWidth)}$s"; + } + + /// return a privateKey from hex string + static ECPrivateKey loadPrivateKey(String storedkey) { + final d = BigInt.parse(storedkey, radix: 16); + final param = ECCurve_secp256k1(); + return ECPrivateKey(d, param); + } + + /// return a publicKey from hex string + static ECPublicKey loadPublicKey(String storedkey) { + final param = ECCurve_secp256k1(); + if (storedkey.length < 120) { + List codeList = []; + for (var idx = 0; idx < storedkey.length - 1; idx += 2) { + final hexStr = storedkey.substring(idx, idx + 2); + codeList.add(int.parse(hexStr, radix: 16)); + } + final Q = param.curve.decodePoint(codeList); + return ECPublicKey(Q, param); + } else { + final x = BigInt.parse(storedkey.substring(0, 64), radix: 16); + final y = BigInt.parse(storedkey.substring(64), radix: 16); + final Q = param.curve.createPoint(x, y); + return ECPublicKey(Q, param); + } + } +} diff --git a/lib/src/crypto/nip_004.dart b/lib/src/crypto/nip_004.dart new file mode 100644 index 0000000..6e49ae7 --- /dev/null +++ b/lib/src/crypto/nip_004.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:nostr/nostr.dart'; +import 'package:nostr/src/crypto/kepler.dart'; +import 'package:pointycastle/export.dart'; + +/// NIP4 cipher +String nip4cipher( + String privkey, + String pubkey, + String payload, + bool cipher, { + String? nonce, +}) { + // if cipher=false –> decipher –> nonce needed + if (!cipher && nonce == null) throw Exception("missing nonce"); + + // init variables + Uint8List input, output, iv; + if (!cipher && nonce != null) { + input = base64.decode(payload); + output = Uint8List(input.length); + iv = base64.decode(nonce); + } else { + input = Utf8Encoder().convert(payload); + output = Uint8List(input.length + 16); + iv = Uint8List.fromList(generateRandomBytes(16)); + } + + // params + List> keplerSecret = Kepler.byteSecret(privkey, pubkey); + var key = Uint8List.fromList(keplerSecret[0]); + var params = PaddedBlockCipherParameters( + ParametersWithIV(KeyParameter(key), iv), + null, + ); + var algo = PaddedBlockCipherImpl( + PKCS7Padding(), + CBCBlockCipher(AESEngine()), + ); + + // processing + algo.init(cipher, params); + var offset = 0; + while (offset < input.length - 16) { + offset += algo.processBlock(input, offset, output, offset); + } + offset += algo.doFinal(input, offset, output, offset); + Uint8List result = output.sublist(0, offset); + + if (cipher) { + String stringIv = base64.encode(iv); + String plaintext = base64.encode(result); + return "$plaintext?iv=$stringIv"; + } else { + return Utf8Decoder().convert(result); + } +} diff --git a/lib/src/crypto/operator.dart b/lib/src/crypto/operator.dart new file mode 100644 index 0000000..231a66b --- /dev/null +++ b/lib/src/crypto/operator.dart @@ -0,0 +1,113 @@ +// credit: https://github.com/tjcampanella/kepler/blob/master/lib/src/operator.dart + +import 'package:pointycastle/export.dart'; + +BigInt theP = BigInt.parse( + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", + radix: 16); +BigInt theN = BigInt.parse( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + radix: 16); + +bool isOnCurve(ECPoint point) { + assert(point.x != null && + point.y != null && + point.curve.a != null && + point.curve.b != null); + final x = point.x!.toBigInteger(); + final y = point.y!.toBigInteger(); + final rs = (y! * y - + x! * x * x - + point.curve.a!.toBigInteger()! * x - + point.curve.b!.toBigInteger()!) % + theP; + return rs == BigInt.from(0); +} + +BigInt inverseMod(BigInt k, BigInt p) { + if (k.compareTo(BigInt.zero) == 0) { + throw Exception("Cannot Divide By 0"); + } + if (k < BigInt.from(0)) { + return p - inverseMod(-k, p); + } + var s = [BigInt.from(0), BigInt.from(1), BigInt.from(1)]; + var t = [BigInt.from(1), BigInt.from(0), BigInt.from(0)]; + var r = [p, k, k]; + while (r[0] != BigInt.from(0)) { + var quotient = r[2] ~/ r[0]; + r[1] = r[2] - quotient * r[0]; + r[2] = r[0]; + r[0] = r[1]; + s[1] = s[2] - quotient * s[0]; + s[2] = s[0]; + s[0] = s[1]; + t[1] = t[2] - quotient * t[0]; + t[2] = t[0]; + t[0] = t[1]; + } + final gcd = r[2]; + final x = s[2]; + // final y = t[2]; + assert(gcd == BigInt.from(1)); + assert((k * x) % p == BigInt.from(1)); + return x % p; +} + +ECPoint pointNeg(ECPoint point) { + assert(isOnCurve(point)); + assert(point.x != null || point.y != null); + final x = point.x!.toBigInteger(); + final y = point.y!.toBigInteger(); + final result = point.curve.createPoint(x!, -y! % theP); + assert(isOnCurve(result)); + return result; +} + +ECPoint pointAdd(ECPoint? point1, ECPoint? point2) { + if (point1 == null) { + return point2!; + } + if (point2 == null) { + return point1; + } + assert(isOnCurve(point1)); + assert(isOnCurve(point2)); + final x1 = point1.x!.toBigInteger(); + final y1 = point1.y!.toBigInteger(); + final x2 = point2.x!.toBigInteger(); + final y2 = point2.y!.toBigInteger(); + + BigInt m; + if (x1 == x2) { + m = (BigInt.from(3) * x1! * x1 + point1.curve.a!.toBigInteger()!) * + inverseMod(BigInt.from(2) * y1!, theP); + } else { + m = (y1! - y2!) * inverseMod(x1! - x2!, theP); + } + final x3 = m * m - x1 - x2!; + final y3 = y1 + m * (x3 - x1); + ECPoint result = point1.curve.createPoint(x3 % theP, -y3 % theP); + assert(isOnCurve(result)); + return result; +} + +ECPoint scalarMultiple(BigInt k, ECPoint point) { + assert(isOnCurve(point)); + assert((k % theN).compareTo(BigInt.zero) != 0); + assert(point.x != null && point.y != null); + if (k < BigInt.from(0)) { + return scalarMultiple(-k, pointNeg(point)); + } + ECPoint? result; + ECPoint addend = point; + while (k > BigInt.from(0)) { + if (k & BigInt.from(1) > BigInt.from(0)) { + result = pointAdd(result, addend); + } + addend = pointAdd(addend, addend); + k >>= 1; + } + assert(isOnCurve(result!)); + return result!; +} diff --git a/lib/src/event.dart b/lib/src/event.dart index 96dbb54..a126a07 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -1,6 +1,7 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:convert/convert.dart'; -import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; import 'package:bip340/bip340.dart' as bip340; import 'package:nostr/src/utils.dart'; @@ -85,7 +86,7 @@ class Event { bool verify = true, }) { pubkey = pubkey.toLowerCase(); - if (verify) assert(isValid() == true); + if (verify && isValid() == false) throw 'Invalid event'; } /// Partial constructor, you have to fill the fields yourself @@ -127,27 +128,26 @@ class Event { ); } - /// Instantiate Event object from the minimum available data + /// Instantiate Event object from the minimum needed data /// /// ```dart ///Event event = Event.from( /// kind: 1, - /// tags: [], /// content: "", /// privkey: /// "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", ///); ///``` factory Event.from({ - int createdAt = 0, + int? createdAt, required int kind, - required List> tags, + List> tags = const [], required String content, required String privkey, String? subscriptionId, bool verify = false, }) { - if (createdAt == 0) createdAt = currentUnixTimestampSeconds(); + createdAt ??= currentUnixTimestampSeconds(); final pubkey = bip340.getPublicKey(privkey).toLowerCase(); final id = _processEventId( @@ -253,7 +253,7 @@ class Event { throw Exception('invalid input'); } - var tags = (json['tags'] as List) + List> tags = (json['tags'] as List) .map((e) => (e as List).map((e) => e as String).toList()) .toList(); @@ -302,7 +302,8 @@ class Event { ) { List data = [0, pubkey.toLowerCase(), createdAt, kind, tags, content]; String serializedEvent = json.encode(data); - List hash = sha256.convert(utf8.encode(serializedEvent)).bytes; + Uint8List hash = SHA256Digest() + .process(Uint8List.fromList(utf8.encode(serializedEvent))); return hex.encode(hash); } diff --git a/lib/src/message.dart b/lib/src/message.dart index 9d3d0c2..b142a94 100644 --- a/lib/src/message.dart +++ b/lib/src/message.dart @@ -11,12 +11,19 @@ class Message { Message.deserialize(String payload) { dynamic data = jsonDecode(payload); var messages = ["EVENT", "REQ", "CLOSE", "NOTICE", "EOSE", "OK", "AUTH"]; - assert(messages.contains(data[0]), "Unsupported payload (or NIP)"); + if (messages.contains(data[0]) == false) { + throw 'Unsupported payload (or NIP)'; + } type = data[0]; switch (type) { case "EVENT": message = Event.deserialize(data); + // ignore: deprecated_member_use_from_same_package + if (message.kind == 4) message = EncryptedDirectMessage(message); + break; + case "OK": + message = Nip20.deserialize(data); break; case "REQ": message = Request.deserialize(data); diff --git a/lib/src/nips/nip_004.dart b/lib/src/nips/nip_004.dart new file mode 100644 index 0000000..e0f777a --- /dev/null +++ b/lib/src/nips/nip_004.dart @@ -0,0 +1,127 @@ +import 'package:bip340/bip340.dart' as bip340; +import 'package:nostr/src/event.dart'; +import 'package:nostr/src/crypto/nip_004.dart'; +import 'package:nostr/src/utils.dart'; + +/// A special event with kind 4, meaning "encrypted direct message". +/// +/// content MUST be equal to the base64-encoded, aes-256-cbc encrypted string of anything a user wants to write, encrypted using a shared nip4cipher generated by combining the recipient's public-key with the sender's private-key; +/// this appended by the base64-encoded initialization vector as if it was a querystring parameter named "iv". +/// The format is the following: "content": "?iv=". +/// +/// tags MUST contain an entry identifying the receiver of the message (such that relays may naturally forward this event to them), in the form ["p", "pubkey, as a hex string"]. +/// +/// tags MAY contain an entry identifying the previous message in a conversation or a message we are explicitly replying to (such that contextual, more organized conversations may happen), in the form ["e", "event_id"]. +/// +/// Note: By default in the libsecp256k1 ECDH implementation, the secret is the SHA256 hash of the shared point (both X and Y coordinates). In Nostr, only the X coordinate of the shared point is used as the secret and it is NOT hashed. If using libsecp256k1, a custom function that copies the X coordinate must be passed as the hashfp argument in secp256k1_ecdh. +/// +/// NIP-04 considered harmful, READ: https://github.com/ethicnology/dart-nostr/issues/15 and https://github.com/nostr-protocol/nips/issues/107 +@Deprecated( + """ + NIP-04 a.k.a EncryptedDirectMessage is controversial, please READ: + - https://github.com/ethicnology/dart-nostr/issues/15 + - https://github.com/nostr-protocol/nips/issues/107 + """, +) +class EncryptedDirectMessage extends Event { + /// Default constructor + EncryptedDirectMessage(Event event, {verify = true}) + : super( + event.id, + event.pubkey, + event.createdAt, + 4, + event.tags, + event.content, + event.sig, + subscriptionId: event.subscriptionId, + verify: verify, + ); + + /// receive an EncryptedDirectMessage + EncryptedDirectMessage.receive(Event event, {verify = true}) + : super( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + subscriptionId: event.subscriptionId, + verify: verify, + ) { + assert(kind == 4); + } + + /// prepare a EncryptedDirectMessage to send quickly with the minimal needed params + factory EncryptedDirectMessage.redact( + String senderPrivkey, + String receiverPubkey, + String message, + ) { + var event = Event.partial( + pubkey: bip340.getPublicKey(senderPrivkey).toLowerCase(), + createdAt: currentUnixTimestampSeconds(), + kind: 4, + tags: [ + ['p', receiverPubkey] + ], + content: nip4cipher( + senderPrivkey, + '02$receiverPubkey', + message, + true, + ), + ); + event.id = event.getEventId(); + event.sig = event.getSignature(senderPrivkey); + return EncryptedDirectMessage(event); + } + + /// get receiver public key + String? get receiver => _findTag("p"); + + /// get sender public key + String? get sender => pubkey; + + /// get previous event id –> MAY contain an entry identifying the previous message in a conversation or a message we are explicitly replying to. + String? get previous => _findTag("e"); + + /// get nonce/IV + String get nonce => _findNonce(); + + /// get the deciphered message a.k.a. plaintext + String getPlaintext(String privkey) { + String ciphertext = content.split("?iv=")[0]; + String plaintext; + try { + plaintext = nip4cipher( + privkey, + "02$pubkey", + ciphertext, + false, + nonce: nonce, + ); + } catch (e) { + throw Exception("Fail to decipher: $e"); + } + return plaintext; + } + + /// find the given tag prefix and return the value if found + String? _findTag(String prefix) { + String prefix = "p"; + for (List tag in tags) { + if (tag.isNotEmpty && tag[0] == prefix && tag.length > 1) return tag[1]; + } + return null; + } + + /// parse the ciphered content to return the nonce/IV + String _findNonce() { + List split = content.split("?iv="); + if (split.length != 2) throw Exception("invalid content or non ciphered"); + return split[1]; + } +} diff --git a/lib/src/nips/nip_005.dart b/lib/src/nips/nip_005.dart new file mode 100644 index 0000000..db0611e --- /dev/null +++ b/lib/src/nips/nip_005.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'package:nostr/nostr.dart'; + +/// Mapping Nostr keys to DNS-based internet identifiers +class Nip5 { + /// decode setmetadata event + /// { + /// "pubkey": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", + /// "kind": 0, + /// "content": "{\"name\": \"bob\", \"nip05\": \"bob@example.com\"}" + /// } + static Future decode(Event event) async { + if (event.kind == 0) { + try { + Map map = jsonDecode(event.content); + String dns = map['nip05']; + List relays = map['relays']; + if (dns.isNotEmpty) { + List parts = dns.split('@'); + String name = parts[0]; + String domain = parts[1]; + return DNS(name, domain, event.pubkey, + relays.map((e) => e.toString()).toList()); + } + } catch (e) { + throw Exception(e.toString()); + } + } + throw Exception("${event.kind} is not nip1 compatible"); + } + + /// encode set metadata event + static Event encode( + String name, String domain, List relays, String privkey) { + if (isValidName(name) && isValidDomain(domain)) { + String content = generateContent(name, domain, relays); + return Event.from(kind: 0, tags: [], content: content, privkey: privkey); + } else { + throw Exception("not a valid name or domain!"); + } + } + + static bool isValidName(String input) { + RegExp regExp = RegExp(r'^[a-z0-9_]+$'); + return regExp.hasMatch(input); + } + + static bool isValidDomain(String domain) { + RegExp regExp = RegExp( + r'^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$', + caseSensitive: false, + ); + return regExp.hasMatch(domain); + } + + static String generateContent( + String name, String domain, List relays) { + Map map = { + 'name': name, + 'nip05': '$name@$domain', + 'relays': relays, + }; + return jsonEncode(map); + } +} + +/// +class DNS { + String name; + + String domain; + + String pubkey; + + List relays; + + /// Default constructor + DNS(this.name, this.domain, this.pubkey, this.relays); +} diff --git a/lib/src/nips/nip_010.dart b/lib/src/nips/nip_010.dart new file mode 100644 index 0000000..f712f0c --- /dev/null +++ b/lib/src/nips/nip_010.dart @@ -0,0 +1,70 @@ +///This NIP describes how to use "e" and "p" tags in text events, +///especially those that are replies to other text events. +///It helps clients thread the replies into a tree rooted at the original event. + +class Nip10 { + ///{ + /// "tags": [ + /// ["e", , , "root"], + /// ["e", , , "reply"], + /// ["p", , ], + /// ... + /// ], + /// ... + /// } + static Thread fromTags(List> tags) { + ETag root = ETag('', '', ''); + List etags = []; + List ptags = []; + for (var tag in tags) { + if (tag[0] == "p") ptags.add(PTag(tag[1], tag[2])); + if (tag[0] == "e") { + if (tag[3] == 'root') { + root = ETag(tag[1], tag[2], tag[3]); + } else { + etags.add(ETag(tag[1], tag[2], tag[3])); + } + } + } + return Thread(root, etags, ptags); + } + + static ETag rootTag(String eventId, String relay) { + return ETag(eventId, relay, 'root'); + } + + static List> toTags(Thread thread) { + List> result = []; + result.add( + ["e", thread.root.eventId, thread.root.relayURL, thread.root.marker]); + for (var etag in thread.etags) { + result.add(["e", etag.eventId, etag.relayURL, etag.marker]); + } + for (var ptag in thread.ptags) { + result.add(["p", ptag.pubkey, ptag.relayURL]); + } + return result; + } +} + +class ETag { + String eventId; + String relayURL; + String marker; // root, reply, mention + + ETag(this.eventId, this.relayURL, this.marker); +} + +class PTag { + String pubkey; + String relayURL; + + PTag(this.pubkey, this.relayURL); +} + +class Thread { + ETag root; + List etags; + List ptags; + Thread(this.root, this.etags, this.ptags); +} diff --git a/lib/src/nips/nip_019.dart b/lib/src/nips/nip_019.dart new file mode 100644 index 0000000..d70bf32 --- /dev/null +++ b/lib/src/nips/nip_019.dart @@ -0,0 +1,91 @@ +import 'package:bech32/bech32.dart'; +import 'package:convert/convert.dart'; + +/// bech32-encoded entities +class Nip19 { + static encodePubkey(String pubkey) { + return bech32Encode("npub", pubkey); + } + + static encodePrivkey(String privkey) { + return bech32Encode("nsec", privkey); + } + + static encodeNote(String noteid) { + return bech32Encode("note", noteid); + } + + static String decodePubkey(String data) { + Map map = bech32Decode(data); + if (map["prefix"] == "npub") { + return map["data"]; + } else { + return ""; + } + } + + static String decodePrivkey(String data) { + Map map = bech32Decode(data); + if (map["prefix"] == "nsec") { + return map["data"]; + } else { + return ""; + } + } + + static String decodeNote(String data) { + Map map = bech32Decode(data); + if (map["prefix"] == "note") { + return map["data"]; + } else { + return ""; + } + } +} + +/// help functions + +String bech32Encode(String prefix, String hexData) { + final data = hex.decode(hexData); + final convertedData = convertBits(data, 8, 5, true); + final bech32Data = Bech32(prefix, convertedData); + return bech32.encode(bech32Data); +} + +Map bech32Decode(String bech32Data) { + final decodedData = bech32.decode(bech32Data); + final convertedData = convertBits(decodedData.data, 5, 8, false); + final hexData = hex.encode(convertedData); + + return {'prefix': decodedData.hrp, 'data': hexData}; +} + +List convertBits(List data, int fromBits, int toBits, bool pad) { + var acc = 0; + var bits = 0; + final maxv = (1 << toBits) - 1; + final result = []; + + for (final value in data) { + if (value < 0 || value >> fromBits != 0) { + throw Exception('Invalid value: $value'); + } + acc = (acc << fromBits) | value; + bits += fromBits; + + while (bits >= toBits) { + bits -= toBits; + result.add((acc >> bits) & maxv); + } + } + + if (pad) { + if (bits > 0) { + result.add((acc << (toBits - bits)) & maxv); + } + } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv) != 0) { + throw Exception('Invalid data'); + } + + return result; +} diff --git a/lib/src/nips/nip_020.dart b/lib/src/nips/nip_020.dart new file mode 100644 index 0000000..7b51506 --- /dev/null +++ b/lib/src/nips/nip_020.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +/// When submitting events to relays, clients currently have no way to know if an event was successfully committed to the database. +/// This NIP introduces the concept of command results which are like NOTICE's except provide more information about if an event was accepted or rejected. +/// +/// Event successfully written to the database: +/// +/// ["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, ""] +/// +/// Event successfully written to the database because of a reason: +/// +/// ["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "pow: difficulty 25>=24"] +/// +/// Event blocked due to ip filter: +/// +/// ["OK", "b1a649ebe8...", false, "blocked: tor exit nodes not allowed"] +/// +/// … +class Nip20 { + late String eventId; + late bool status; + late String message; + + /// Default constructor + Nip20(this.eventId, this.status, this.message); + + /// Serialize to nostr close message + /// - ["OK", "event_id", true|false, "message"] + String serialize() => jsonEncode(["OK", eventId, status, message]); + + /// Deserialize a nostr close message + /// - ["OK", "event_id", true|false, "message"] + Nip20.deserialize(input) { + assert(input.length == 4); + eventId = input[1]; + status = input[2]; + message = input[3]; + } +} diff --git a/lib/src/nips/nip_028.dart b/lib/src/nips/nip_028.dart new file mode 100644 index 0000000..ba85de3 --- /dev/null +++ b/lib/src/nips/nip_028.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; +import 'package:nostr/nostr.dart'; + +/// Public Chat & Channel +class Nip28 { + static Channel getChannelCreation(Event event) { + try { + Map content = jsonDecode(event.content); + if (event.kind == 40) { + // create channel + Map additional = Map.from(content); + String? name = additional.remove("name"); + String? about = additional.remove("about"); + String? picture = additional.remove("picture"); + return Channel( + event.id, name!, about!, picture!, event.pubkey, additional); + } else { + throw Exception("${event.kind} is not nip28 compatible"); + } + } catch (e) { + throw Exception(e.toString()); + } + } + + static Channel getChannelMetadata(Event event) { + try { + Map content = jsonDecode(event.content); + if (event.kind == 41) { + // create channel + Map additional = Map.from(content); + String? name = additional.remove("name"); + String? about = additional.remove("about"); + String? picture = additional.remove("picture"); + String? channelId; + String? relay; + for (var tag in event.tags) { + if (tag[0] == "e") { + channelId = tag[1]; + relay = tag[2]; + } + } + Channel result = Channel( + channelId!, name!, about!, picture!, event.pubkey, additional); + result.relay = relay; + return result; + } else { + throw Exception("${event.kind} is not nip28 compatible"); + } + } catch (e) { + throw Exception(e.toString()); + } + } + + static ChannelMessage getChannelMessage(Event event) { + try { + if (event.kind == 42) { + var content = event.content; + Thread thread = Nip10.fromTags(event.tags); + String channelId = thread.root.eventId; + return ChannelMessage( + channelId, event.pubkey, content, thread, event.createdAt); + } + throw Exception("${event.kind} is not nip28 compatible"); + } catch (e) { + throw Exception(e.toString()); + } + } + + static ChannelMessageHidden getMessageHidden(Event event) { + try { + if (event.kind == 43) { + String? messageId; + for (var tag in event.tags) { + if (tag[0] == "e") { + messageId = tag[1]; + break; + } + } + Map content = jsonDecode(event.content); + String reason = content['reason']; + return ChannelMessageHidden( + event.pubkey, messageId!, reason, event.createdAt); + } + throw Exception("${event.kind} is not nip28(hide message) compatible"); + } catch (e) { + throw Exception(e.toString()); + } + } + + static ChannelUserMuted getUserMuted(Event event) { + try { + if (event.kind == 44) { + String? userPubkey; + for (var tag in event.tags) { + if (tag[0] == "p") { + userPubkey = tag[1]; + break; + } + } + Map content = jsonDecode(event.content); + String reason = content['reason']; + return ChannelUserMuted( + event.pubkey, userPubkey!, reason, event.createdAt); + } + throw Exception("${event.kind} is not nip28(mute user) compatible"); + } catch (e) { + throw Exception(e.toString()); + } + } + + static Event createChannel(String name, String about, String picture, + Map additional, String privkey) { + Map map = { + 'name': name, + 'about': about, + 'picture': picture, + }; + map.addAll(additional); + return Event.from( + kind: 40, + tags: [], + content: jsonEncode(map), + privkey: privkey, + ); + } + + static Event setChannelMetaData( + String name, + String about, + String picture, + Map additional, + String channelId, + String relayURL, + String privkey) { + Map map = { + 'name': name, + 'about': about, + 'picture': picture + }; + map.addAll(additional); + return Event.from( + kind: 41, + tags: [ + ["e", channelId, relayURL] + ], + content: jsonEncode(map), + privkey: privkey, + ); + } + + static Event sendChannelMessage( + String channelId, String content, String privkey, + {String? relay, List? etags, List? ptags}) { + Thread thread = + Thread(Nip10.rootTag(channelId, relay ?? ''), etags ?? [], ptags ?? []); + return Event.from( + kind: 42, + tags: Nip10.toTags(thread), + content: content, + privkey: privkey, + ); + } + + static Event hideChannelMessage( + String messageId, String reason, String privkey) { + Map map = {'reason': reason}; + return Event.from( + kind: 43, + tags: [ + ["e", messageId] + ], + content: jsonEncode(map), + privkey: privkey, + ); + } + + static Event muteUser(String pubkey, String reason, String privkey) { + Map map = {'reason': reason}; + return Event.from( + kind: 44, + tags: [ + ["p", pubkey] + ], + content: jsonEncode(map), + privkey: privkey); + } +} + +/// channel info +class Channel { + /// channel create event id + String channelId; + + String name; + + String about; + + String picture; + + String owner; + + String? relay; + + /// Clients MAY add additional metadata fields. + Map additional; + + /// Default constructor + Channel(this.channelId, this.name, this.about, this.picture, this.owner, + this.additional); +} + +/// messages in channel +class ChannelMessage { + String channelId; + String sender; + String content; + Thread thread; + int createTime; + + ChannelMessage( + this.channelId, this.sender, this.content, this.thread, this.createTime); +} + +class ChannelMessageHidden { + String operator; + String messageId; + String reason; + int createTime; + + ChannelMessageHidden( + this.operator, this.messageId, this.reason, this.createTime); +} + +class ChannelUserMuted { + String operator; + String userPubkey; + String reason; + int createTime; + + ChannelUserMuted( + this.operator, this.userPubkey, this.reason, this.createTime); +} diff --git a/lib/src/nips/nip_051.dart b/lib/src/nips/nip_051.dart new file mode 100644 index 0000000..52990f1 --- /dev/null +++ b/lib/src/nips/nip_051.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'package:bip340/bip340.dart' as bip340; +import 'package:nostr/nostr.dart'; +import '../crypto/nip_004.dart'; + +/// Lists +class Nip51 { + static List> peoplesToTags(List items) { + List> result = []; + for (People item in items) { + result.add([ + "p", + item.pubkey, + item.mainRelay ?? "", + item.petName ?? "", + item.aliasPubKey ?? "", + ]); + } + return result; + } + + static List> bookmarksToTags(List items) { + List> result = []; + for (String item in items) { + result.add(["e", item]); + } + return result; + } + + static String peoplesToContent( + List items, String privkey, String pubkey) { + var list = []; + for (People item in items) { + list.add([ + 'p', + item.pubkey, + item.mainRelay ?? "", + item.petName ?? "", + item.aliasPubKey ?? "", + ]); + } + String content = jsonEncode(list); + return nip4cipher(privkey, '02$pubkey', content, true); + } + + static String bookmarksToContent( + List items, String privkey, String pubkey) { + var list = []; + for (String item in items) { + list.add(['e', item]); + } + String content = jsonEncode(list); + return nip4cipher(privkey, '02$pubkey', content, true); + } + + static Map fromContent( + String content, String privkey, String pubkey) { + List people = []; + List bookmarks = []; + int ivIndex = content.indexOf("?iv="); + if (ivIndex <= 0) { + throw Exception("Invalid content, could not get ivIndex: $content"); + } + String iv = content.substring(ivIndex + "?iv=".length, content.length); + String encString = content.substring(0, ivIndex); + String deContent = + nip4cipher(privkey, "02$pubkey", encString, false, nonce: iv); + for (List tag in jsonDecode(deContent)) { + if (tag[0] == "p") { + people.add(People(tag[1], tag.length > 2 ? tag[2] : "", + tag.length > 3 ? tag[3] : "", tag.length > 4 ? tag[4] : "")); + } else if (tag[0] == "e") { + // bookmark + bookmarks.add(tag[1]); + } + } + return {"people": people, "bookmarks": bookmarks}; + } + + static Event createMutePeople(List items, List encryptedItems, + String privkey, String pubkey) { + return Event.from( + kind: 10000, + tags: peoplesToTags(items), + content: peoplesToContent(encryptedItems, privkey, pubkey), + privkey: privkey); + } + + static createPinEvent(List items, List encryptedItems, + String privkey, String pubkey) { + return Event.from( + kind: 10001, + tags: bookmarksToTags(items), + content: bookmarksToContent(encryptedItems, privkey, pubkey), + privkey: privkey); + } + + static Event createCategorizedPeople(String identifier, List items, + List encryptedItems, String privkey, String pubkey) { + List> tags = peoplesToTags(items); + tags.add(["d", identifier]); + return Event.from( + kind: 30000, + tags: tags, + content: peoplesToContent(encryptedItems, privkey, pubkey), + privkey: privkey); + } + + static createCategorizedBookmarks(String identifier, List items, + List encryptedItems, String privkey, String pubkey) { + List> tags = bookmarksToTags(items); + tags.add(["d", identifier]); + return Event.from( + kind: 30001, + tags: tags, + content: bookmarksToContent(encryptedItems, privkey, pubkey), + privkey: privkey); + } + + static Lists getLists(Event event, String privkey) { + if (event.kind != 10000 && + event.kind != 10001 && + event.kind != 30000 && + event.kind != 30001) { + throw Exception("${event.kind} is not nip51 compatible"); + } + String identifier = ""; + List people = []; + List bookmarks = []; + for (List tag in event.tags) { + if (tag[0] == "p") { + people.add(People(tag[1], tag.length > 2 ? tag[2] : "", + tag.length > 3 ? tag[3] : "", tag.length > 4 ? tag[4] : "")); + } + if (tag[0] == "e") { + bookmarks.add(tag[1]); + } + if (tag[0] == "d") identifier = tag[1]; + } + String pubkey = bip340.getPublicKey(privkey); + Map content = Nip51.fromContent(event.content, privkey, pubkey); + people.addAll(content["people"]); + bookmarks.addAll(content["bookmarks"]); + if (event.kind == 10000) identifier = "Mute"; + if (event.kind == 10001) identifier = "Pin"; + + return Lists(event.pubkey, identifier, people, bookmarks); + } +} + +/// +class People { + String pubkey; + String? mainRelay; + String? petName; + String? aliasPubKey; + + /// Default constructor + People(this.pubkey, this.mainRelay, this.petName, this.aliasPubKey); +} + +class Lists { + String owner; + + String identifier; + + List people; + + List bookmarks; + + /// Default constructor + Lists(this.owner, this.identifier, this.people, this.bookmarks); +} diff --git a/lib/src/request.dart b/lib/src/request.dart index 26ca262..6b71bfc 100644 --- a/lib/src/request.dart +++ b/lib/src/request.dart @@ -23,7 +23,7 @@ class Request { /// Deserialize a nostr request message /// - ["REQ", subscription_id, filter JSON, filter JSON, ...] Request.deserialize(input) { - assert(input.length >= 3); + if (input.length < 3) throw 'Message too short'; subscriptionId = input[1]; filters = []; for (var i = 2; i < input.length; i++) { diff --git a/lib/src/utils.dart b/lib/src/utils.dart index b4269a6..37fd8e0 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -4,8 +4,7 @@ import 'package:convert/convert.dart'; /// generates 32 random bytes converted in hex String generate64RandomHexChars() { - final random = Random.secure(); - final randomBytes = List.generate(32, (i) => random.nextInt(256)); + final randomBytes = generateRandomBytes(32); return hex.encode(randomBytes); } @@ -13,3 +12,9 @@ String generate64RandomHexChars() { int currentUnixTimestampSeconds() { return DateTime.now().millisecondsSinceEpoch ~/ 1000; } + +/// generates the requested quantity of random secure bytes +List generateRandomBytes(int quantity) { + final random = Random.secure(); + return List.generate(quantity, (i) => random.nextInt(256)); +} diff --git a/pubspec.yaml b/pubspec.yaml index 8e9561c..0f9efea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,4 +12,7 @@ dev_dependencies: dependencies: bip340: ^0.1.0 convert: ^3.1.1 - crypto: ^3.0.2 + pointycastle: ^3.7.3 + bech32: ^0.2.2 + + \ No newline at end of file diff --git a/test/nips/nip_004_test.dart b/test/nips/nip_004_test.dart new file mode 100644 index 0000000..540f1d4 --- /dev/null +++ b/test/nips/nip_004_test.dart @@ -0,0 +1,58 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +String bobPubkey = + "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"; +String alicePubkey = + "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181"; +String bobPrivkey = + "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; +String alicePrivkey = + "773dc29ff81f7680eeca5d530f528e8c572979b46abc8bfd1586b73a6a98ab4d"; + +void main() { + group('EncryptedDirectMessage', () { + test('EncryptedDirectMessage.quick', () { + // DM from bob to alice + String plaintext = "vi veri universum vivus vici"; + List> tags = [ + ['p', alicePubkey] + ]; + + EncryptedDirectMessage event = + EncryptedDirectMessage.redact(bobPrivkey, alicePubkey, plaintext); + + expect(event.receiver, alicePubkey); + expect(event.getPlaintext(alicePrivkey), plaintext); + expect(event.pubkey, bobPubkey); + expect(event.kind, 4); + expect(event.tags, tags); + expect(event.subscriptionId, null); + }); + + test('EncryptedDirectMessage Receive', () { + String receivedEvent = + '["EVENT", "181555e0fec2139d27ac80a5a46801415394e61d67be7f626631760dc5997bc0", {"id": "2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1", "pubkey": "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181", "created_at": 1680475069, "kind": 4, "tags": [["p", "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"]], "content": "hH1HlQWY3dz7IzJlgnEgW1WNtA0KlvGgo/OC4mep/R4I6PMqJuvZ35j4OFHkMvqb?iv=jbkXPH2esn5DIP3BodpsTQ==", "sig": "ba2d80d8be4612e8438447d38373e9b014b28172d49b7b6afeff462bc25bd4edc69c6e2c145dde052de54f47f4e36d74151fad3d08d21e3cad9563edc64adcca"}]'; + + Message m = Message.deserialize(receivedEvent); + Event event = m.message; + expect(event.id, + "2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1"); + expect(event.pubkey, alicePubkey); + expect(event.createdAt, 1680475069); + expect(event.kind, 4); + expect(event.tags, [ + ["p", bobPubkey] + ]); + String content = + (event as EncryptedDirectMessage).getPlaintext(bobPrivkey); + expect(content, "Secret message from alice to bob!"); + expect(event.sig, + "ba2d80d8be4612e8438447d38373e9b014b28172d49b7b6afeff462bc25bd4edc69c6e2c145dde052de54f47f4e36d74151fad3d08d21e3cad9563edc64adcca"); + expect(event.subscriptionId, + "181555e0fec2139d27ac80a5a46801415394e61d67be7f626631760dc5997bc0"); + }); + }); +} diff --git a/test/nips/nip_005_test.dart b/test/nips/nip_005_test.dart new file mode 100644 index 0000000..215e794 --- /dev/null +++ b/test/nips/nip_005_test.dart @@ -0,0 +1,40 @@ +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +void main() { + group('nip005', () { + test('encode', () { + var hex = + "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12"; + var user = Keychain(hex); + List relays = [ + 'wss://relay.example.com', + 'wss://relay2.example.com' + ]; + Event event = Nip5.encode('name', 'example.com', relays, user.private); + expect(event.kind, 0); + + expect(() => Nip5.encode('name', 'example', relays, user.private), + throwsException); + expect(() => Nip5.encode('name!', 'example.com', relays, user.private), + throwsException); + }); + + test('decode', () async { + var event = Event.from( + kind: 0, + tags: [], + content: + "{\"name\":\"name\",\"nip05\":\"name@example.com\",\"relays\":[\"wss://relay.example.com\",\"wss://relay2.example.com\"]}", + privkey: + "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", + ); + DNS? dns = await Nip5.decode(event); + expect(dns!.name, 'name'); + expect(dns.domain, 'example.com'); + expect(dns.pubkey, event.pubkey); + expect( + dns.relays, ['wss://relay.example.com', 'wss://relay2.example.com']); + }); + }); +} diff --git a/test/nips/nip_010_test.dart b/test/nips/nip_010_test.dart new file mode 100644 index 0000000..e74ecea --- /dev/null +++ b/test/nips/nip_010_test.dart @@ -0,0 +1,36 @@ +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +void main() { + group('nip010', () { + test('fromTags', () { + List> tags = [ + ["e", '91cf9..4e5ca', 'wss://alicerelay.com', "root"], + ["e", '14aeb..8dad4', 'wss://bobrelay.com/nostr', "reply"], + ["p", '612ae..e610f', 'ws://carolrelay.com/ws'], + ]; + Thread thread = Nip10.fromTags(tags); + expect(thread.root.eventId, '91cf9..4e5ca'); + expect(thread.root.relayURL, 'wss://alicerelay.com'); + expect(thread.root.marker, 'root'); + + expect(thread.etags[0].eventId, '14aeb..8dad4'); + expect(thread.etags[0].relayURL, 'wss://bobrelay.com/nostr'); + expect(thread.etags[0].marker, 'reply'); + + expect(thread.ptags[0].pubkey, '612ae..e610f'); + expect(thread.ptags[0].relayURL, 'ws://carolrelay.com/ws'); + }); + + test('toTags', () { + ETag root = Nip10.rootTag('91cf9..4e5ca', 'wss://alicerelay.com'); + ETag eTag = ETag("14aeb..8dad4", "wss://bobrelay.com/nostr", "reply"); + PTag pTag = PTag("612ae..e610f", "ws://carolrelay.com/ws"); + Thread thread = Thread(root, [eTag], [pTag]); + + expect(thread.root.eventId, '91cf9..4e5ca'); + expect(thread.etags[0].eventId, "14aeb..8dad4"); + expect(thread.ptags[0].pubkey, "612ae..e610f"); + }); + }); +} diff --git a/test/nips/nip_019_test.dart b/test/nips/nip_019_test.dart new file mode 100644 index 0000000..2bcfa6d --- /dev/null +++ b/test/nips/nip_019_test.dart @@ -0,0 +1,38 @@ +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +void main() { + group('nip019', () { + test('encode', () { + String encodedPrivkey = Nip19.encodePrivkey( + "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12"); + String encodedPubkey = Nip19.encodePubkey( + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"); + String encodedNote = Nip19.encodeNote( + "2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1"); + + expect(encodedPrivkey, + 'nsec1tmsusqq2k28d6exhff7e2xkzm42es9yg0vdeuxk8chufa9sjtsfq8z3spp'); + expect(encodedPubkey, + 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6'); + expect(encodedNote, + 'note1yuuuenwrl22r44z8x78zxnh3xfd8dupr595mfqakl6x2k3a8j0ssj0w27g'); + }); + + test('decode', () { + String privkey = Nip19.decodePrivkey( + "nsec1tmsusqq2k28d6exhff7e2xkzm42es9yg0vdeuxk8chufa9sjtsfq8z3spp"); + String pubkey = Nip19.decodePubkey( + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"); + String note = Nip19.decodeNote( + "note1yuuuenwrl22r44z8x78zxnh3xfd8dupr595mfqakl6x2k3a8j0ssj0w27g"); + + expect(privkey, + '5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12'); + expect(pubkey, + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'); + expect(note, + '2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1'); + }); + }); +} diff --git a/test/nips/nip_020_test.dart b/test/nips/nip_020_test.dart new file mode 100644 index 0000000..a4e643a --- /dev/null +++ b/test/nips/nip_020_test.dart @@ -0,0 +1,40 @@ +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +void main() { + group('nip20', () { + test('Constructor', () { + var eventId = + "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30"; + var status = true; + var message = ""; + var nip20 = Nip20(eventId, status, message); + expect(nip20.eventId, eventId); + expect(nip20.status, status); + expect(nip20.message, message); + }); + + test('serialize', () { + var eventId = + "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30"; + var status = true; + var message = ""; + var stringNip20 = '["OK","$eventId",$status,"$message"]'; + var nip20 = Nip20(eventId, status, message); + expect(nip20.serialize(), stringNip20); + }); + + test('deserialize', () { + var jsonNip20 = [ + "OK", + "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", + true, + "" + ]; + var nip20 = Nip20.deserialize(jsonNip20); + expect(nip20.eventId, jsonNip20[1]); + expect(nip20.status, jsonNip20[2]); + expect(nip20.message, jsonNip20[3]); + }); + }); +} diff --git a/test/nips/nip_028_test.dart b/test/nips/nip_028_test.dart new file mode 100644 index 0000000..c5eb1e8 --- /dev/null +++ b/test/nips/nip_028_test.dart @@ -0,0 +1,114 @@ +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +void main() { + group('nip028', () { + test('createChannel', () { + String privkey = + "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; + Event event = Nip28.createChannel( + 'name', + 'about', + 'http://image.jpg', + { + 'badges': + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181' + }, + privkey); + Channel channel = Nip28.getChannelCreation(event); + expect(channel.picture, 'http://image.jpg'); + expect(channel.additional['badges'], + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); + }); + + test('setMetadata', () { + String privkey = + "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; + Event event = Nip28.setChannelMetaData( + 'name', + 'about', + 'http://image.jpg', + { + 'badges': + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181' + }, + 'b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9', + 'wss://example.com', + privkey); + Channel channel = Nip28.getChannelMetadata(event); + expect(channel.channelId, + "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9"); + expect(channel.picture, 'http://image.jpg'); + expect(channel.additional['badges'], + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); + }); + + test('sendChannelMessage', () { + String privkey = + "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; + Event event = Nip28.sendChannelMessage( + "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", + 'content', + privkey); + ChannelMessage channelMessage = Nip28.getChannelMessage(event); + + expect(channelMessage.channelId, + "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9"); + expect(channelMessage.content, 'content'); + + /// reply & p + ETag eTag = ETag( + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181', + "wss://example.com", + 'reply'); + PTag pTag = PTag( + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1', + "wss://example.com"); + Event event2 = Nip28.sendChannelMessage( + "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", + 'content', + privkey, + etags: [eTag], + ptags: [pTag]); + ChannelMessage channelMessage2 = Nip28.getChannelMessage(event2); + + expect(channelMessage2.channelId, + "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9"); + expect(channelMessage2.content, 'content'); + expect(channelMessage2.thread.etags[0].eventId, + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); + expect(channelMessage2.thread.ptags[0].pubkey, + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); + }); + + test('hideChannelMessage', () { + String privkey = + "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; + Event event = Nip28.hideChannelMessage( + "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", + "reason", + privkey); + ChannelMessageHidden channelMessageHidden = Nip28.getMessageHidden(event); + + expect(channelMessageHidden.operator, + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); + expect(channelMessageHidden.messageId, + 'b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9'); + }); + + test('muteUser', () { + String privkey = + "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; + Event event = Nip28.muteUser( + "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", + "reason", + privkey); + ChannelUserMuted channelUserMuted = Nip28.getUserMuted(event); + + expect(channelUserMuted.operator, + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); + expect(channelUserMuted.userPubkey, + 'b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9'); + }); + }); +} diff --git a/test/nips/nip_051_test.dart b/test/nips/nip_051_test.dart new file mode 100644 index 0000000..b7913b7 --- /dev/null +++ b/test/nips/nip_051_test.dart @@ -0,0 +1,85 @@ +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +void main() { + group('nip051', () { + test('createCategorizedPeople', () { + Keychain user = Keychain.generate(); + People publicFriend = People( + "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1", + 'wss://example.com', + 'alias', + "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181"); + People privateFriend = People( + "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181", + 'wss://example2.com', + 'bob', + "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"); + Event event = Nip51.createCategorizedPeople("friends", [publicFriend], + [privateFriend], user.private, user.public); + + Lists lists = Nip51.getLists(event, user.private); + expect(lists.people[0].pubkey, + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); + expect(lists.people[0].petName, 'alias'); + expect(lists.people[1].pubkey, + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); + expect(lists.people[1].petName, 'bob'); + }); + + test('createCategorizedBookmarks', () { + Keychain user = Keychain.generate(); + String bookmark = + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'; + String encryptedBookmark = + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'; + Event event = Nip51.createCategorizedBookmarks("bookmarks", [bookmark], + [encryptedBookmark], user.private, user.public); + + Lists lists = Nip51.getLists(event, user.private); + expect(lists.bookmarks[0], + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); + expect(lists.bookmarks[1], + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); + }); + + test('createMutePeople', () { + Keychain user = Keychain.generate(); + People publicFriend = People( + "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1", + 'wss://example.com', + 'alias', + "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181"); + People privateFriend = People( + "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181", + 'wss://example2.com', + 'bob', + "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"); + Event event = Nip51.createMutePeople( + [publicFriend], [privateFriend], user.private, user.public); + Lists lists = Nip51.getLists(event, user.private); + expect(lists.people[0].pubkey, + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); + expect(lists.people[0].petName, 'alias'); + expect(lists.people[1].pubkey, + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); + expect(lists.people[1].petName, 'bob'); + }); + + test('createPinEvent', () { + Keychain user = Keychain.generate(); + String bookmark = + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'; + String encryptedBookmark = + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'; + Event event = Nip51.createPinEvent( + [bookmark], [encryptedBookmark], user.private, user.public); + + Lists lists = Nip51.getLists(event, user.private); + expect(lists.bookmarks[0], + '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); + expect(lists.bookmarks[1], + '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); + }); + }); +}