diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e05cd84 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022 cnixbtc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..dbc7198 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "secp256k1.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GigaBitcoin/secp256k1.swift.git", + "state" : { + "revision" : "b80388789a8058fc99202436225f8f15d35dfbea", + "version" : "0.8.1" + } + }, + { + "identity" : "starscream", + "kind" : "remoteSourceControl", + "location" : "https://github.com/daltoniam/Starscream", + "state" : { + "revision" : "e6b65c6d9077ea48b4a7bdda8994a1d3c6969c8d", + "version" : "3.1.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "d9825fa541df64b1a7b182178d61b9a82730d01f", + "version" : "2.1.0" + } + }, + { + "identity" : "swift-nio-zlib-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-zlib-support.git", + "state" : { + "revision" : "37760e9a52030bb9011972c5213c3350fa9d41fd", + "version" : "1.0.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 390b02a..a40108e 100644 --- a/Package.swift +++ b/Package.swift @@ -4,14 +4,35 @@ import PackageDescription let package = Package( name: "NostrKit", + platforms: [ + .macOS(.v11), + ], products: [ .library(name: "NostrKit", targets: ["NostrKit"]), - .executable(name: "example", targets: ["Example"]), + .executable(name: "example-reader", targets: ["ExampleReader"]), + .executable(name: "example-writer", targets: ["ExampleWriter"]), + ], + dependencies: [ + .package(url: "https://github.com/GigaBitcoin/secp256k1.swift", exact: .init(stringLiteral: "0.8.1")), + .package(url: "https://github.com/apple/swift-crypto.git", exact: .init(stringLiteral: "2.1.0")), + .package(url: "https://github.com/daltoniam/Starscream", exact: .init(stringLiteral: "3.1.1")), ], - dependencies: [], targets: [ - .target(name: "NostrKit", dependencies: []), - .testTarget(name: "NostrKitTests", dependencies: ["NostrKit"]), - .executableTarget(name: "Example", dependencies: ["NostrKit"]), + .target(name: "NostrKit", dependencies: [ + .product(name: "secp256k1", package: "secp256k1.swift"), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "Starscream", package: "Starscream"), + ]), + .testTarget(name: "NostrKitTests", dependencies: [ + .target(name: "NostrKit"), + ]), + .executableTarget(name: "ExampleReader", dependencies: [ + .target(name: "NostrKit"), + .product(name: "Starscream", package: "Starscream"), + ]), + .executableTarget(name: "ExampleWriter", dependencies: [ + .target(name: "NostrKit"), + .product(name: "Starscream", package: "Starscream"), + ]), ] ) diff --git a/README.md b/README.md index 93034f1..0b5b1fa 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -# NostrKit +# NostrKit + +A Swift library for interacting with a [Nostr](https://github.com/nostr-protocol/nostr) relay. + +## Installation + +NostrKit is available as a [Swift Package Manager](https://swift.org/package-manager/) package. +To use it, add the following dependency to your `Package.swift` file: + +``` swift +.package(url: "https://github.com/cnixbtc/NostrKit.git", from: "0.1.0"), +``` + +## Functionality + +NostrKit can be used to publish events on a Nostr relay as well as request events and subscribe to new updates. + +### Subscribing to Events + +``` swift +let keyPair = try KeyPair(privateKey: "") +let relay = Relay(url: URL("")!, onEvent: { print($0) }) + +let subscription = Subscription(filters: [ + .init(authors: [keyPair.publicKey]) +]) + +try await relay.connect() +try await relay.subscribe(to: subscription) + +// later on... + +try await relay.unsubscribe(from: subscription.id) +``` + +### Publishing Events + +``` swift +let keyPair = try KeyPair(privateKey: "") +let relay = Relay(url: URL("")!) + +let event = try Event(keyPair: keyPair, content: "Hello NostrKit.") + +try await relay.connect() +try await relay.send(event: event) +``` + +Fully functional code examples can be found in `Sources/ExampleReader` as well as `Sources/ExampleWriter`. +Run `swift run example-reader` and `swift run example-writer` to see them in action. diff --git a/Sources/Example/Example.swift b/Sources/Example/Example.swift deleted file mode 100644 index ca16b6c..0000000 --- a/Sources/Example/Example.swift +++ /dev/null @@ -1,6 +0,0 @@ -import NostrKit - -@main -struct NostrKitExample { - static func main() { } -} diff --git a/Sources/ExampleReader/main.swift b/Sources/ExampleReader/main.swift new file mode 100644 index 0000000..aa12d69 --- /dev/null +++ b/Sources/ExampleReader/main.swift @@ -0,0 +1,30 @@ +import Foundation +import Starscream +import NostrKit + +// See the `docker/` directory in the project root for a local relay to use in development and testing. +let relayUrl = URL(string: "http://localhost:8080")! + +// This is just a dummy key pair. Don't use it like this in production. +let keyPair = try KeyPair(privateKey: "df9aae2ac8233ffa210a086c54059d02ba3247dab1130dad968f28f036326a83") + +let relay = Relay(url: relayUrl, onEvent: { message in + print(message) +}) + +let subscription = Subscription(filters: [ + .init(authors: [keyPair.publicKey]) +]) + +Task { + do { + try await relay.connect() + try await relay.subscribe(to: subscription) + } catch { + print("Something went wrong: \(error)") + + exit(EXIT_FAILURE) + } +} + +RunLoop.current.run() diff --git a/Sources/ExampleWriter/main.swift b/Sources/ExampleWriter/main.swift new file mode 100644 index 0000000..4b3d23d --- /dev/null +++ b/Sources/ExampleWriter/main.swift @@ -0,0 +1,28 @@ +import Foundation +import Starscream +import NostrKit + +// See the `docker/` directory in the project root for a local relay to use in development and testing. +let relayUrl = URL(string: "http://localhost:8080")! + +// This is just a dummy key pair. Don't use it like this in production. +let keyPair = try KeyPair(privateKey: "df9aae2ac8233ffa210a086c54059d02ba3247dab1130dad968f28f036326a83") + +let relay = Relay(url: relayUrl) + +let event = try Event(keyPair: keyPair, content: "Hello NostrKit.") + +Task { + do { + try await relay.connect() + try await relay.send(event: event) + + exit(EXIT_SUCCESS) + } catch { + print("Something went wrong: \(error)") + + exit(EXIT_FAILURE) + } +} + +RunLoop.current.run() diff --git a/Sources/NostrKit/ClientMessage.swift b/Sources/NostrKit/ClientMessage.swift new file mode 100644 index 0000000..8ada603 --- /dev/null +++ b/Sources/NostrKit/ClientMessage.swift @@ -0,0 +1,29 @@ +import Foundation + +enum ClientMessage: Encodable { + case event(Event) + case subscribe(Subscription) + case unsubscribe(SubscriptionId) + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + switch self { + case .event(let event): + try container.encode("EVENT") + try container.encode(event) + case .subscribe(let subscription): + try container.encode("REQ") + try container.encode(subscription.id) + try subscription.filters.forEach { try container.encode($0) } + case .unsubscribe(let subscriptionId): + try container.encode("CLOSE") + try container.encode(subscriptionId) + } + } + + func string() throws -> String { + return String(data: try JSONEncoder().encode(self), encoding: .utf8)! + } +} + diff --git a/Sources/NostrKit/DataExtensions.swift b/Sources/NostrKit/DataExtensions.swift new file mode 100644 index 0000000..d1fc701 --- /dev/null +++ b/Sources/NostrKit/DataExtensions.swift @@ -0,0 +1,40 @@ +import Foundation + +private extension Collection { + func unfoldSubSequences(ofMaxLength maxSequenceLength: Int) -> UnfoldSequence { + sequence(state: startIndex) { current in + guard current < endIndex else { return nil } + + let upperBound = index(current, offsetBy: maxSequenceLength, limitedBy: endIndex) ?? endIndex + defer { current = upperBound } + + return self[current.. String { + return self.map { String(format: "%02hhx", $0) }.joined() + } + + init(hex: String) throws { + guard hex.count.isMultiple(of: 2) else { throw DecodingError.oddNumberOfCharacters } + + self = .init(capacity: hex.utf8.count / 2) + + for pair in hex.unfoldSubSequences(ofMaxLength: 2) { + guard let byte = UInt8(pair, radix: 16) else { + let invalidCharacters = Array(pair.filter({ !$0.isHexDigit })) + throw DecodingError.invalidHexCharacters(invalidCharacters) + } + + append(byte) + } + } +} diff --git a/Sources/NostrKit/Event.swift b/Sources/NostrKit/Event.swift new file mode 100644 index 0000000..409ff93 --- /dev/null +++ b/Sources/NostrKit/Event.swift @@ -0,0 +1,156 @@ +import Foundation +import Crypto + +public typealias EventId = String + +public enum EventError: Error { + case encodingFailed + case signingFailed +} + +public enum EventKind: Codable { + case setMetadata + case textNote + case recommentServer + case custom(Int) + + init(id: Int) { + switch id { + case 0: self = .setMetadata + case 1: self = .textNote + case 2: self = .recommentServer + default: self = .custom(id) + } + } + + var id: Int { + switch self { + case .setMetadata: return 0 + case .textNote: return 1 + case .recommentServer: return 2 + case .custom(let customId): return customId + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + self.init(id: try container.decode(Int.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(self.id) + } +} + +public struct EventTag: Codable { + private let underlyingData: [String] + + public var id: String { + return underlyingData.first! + } + + public var otherInformation: [String] { + return Array(underlyingData.suffix(from: 1)) + } + + public static func event(otherEventId: String, recommendedRelay: URL? = nil) -> EventTag { + return EventTag(id: "e", otherInformation: otherEventId, recommendedRelay?.absoluteString) + } + + public static func pubKey(publicKey: String, recommendedRelay: URL? = nil) -> EventTag { + return EventTag(id: "p", otherInformation: publicKey, recommendedRelay?.absoluteString) + } + + public init(id: String, otherInformation: String?...) { + underlyingData = [id] + otherInformation.compactMap { $0 } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + underlyingData = try container.decode([String].self) + + guard underlyingData.count > 0 else { + throw DecodingError.dataCorrupted(.init(codingPath: .init(), debugDescription: "missing required tag id")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(contentsOf: underlyingData) + } +} + +private struct SerializableEvent: Encodable { + let id = 0 + let publicKey: String + let createdAt: Timestamp + let kind: EventKind + let tags: [EventTag] + let content: String + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(id) + try container.encode(publicKey) + try container.encode(createdAt) + try container.encode(kind) + try container.encode(tags) + try container.encode(content) + } +} + +public struct Event: Codable { + public let id: EventId + public let publicKey: String + public let createdAt: Timestamp + public let kind: EventKind + public let tags: [EventTag] + public let content: String + public let signature: String + + enum CodingKeys: String, CodingKey { + case id + case publicKey = "pubkey" + case createdAt = "created_at" + case kind + case tags + case content + case signature = "sig" + } + + public init(keyPair: KeyPair, kind: EventKind = .textNote, tags: [EventTag] = [], content: String) throws { + publicKey = keyPair.publicKey + createdAt = Timestamp(date: Date()) + self.kind = kind + self.tags = tags + self.content = content + + let serializableEvent = SerializableEvent( + publicKey: publicKey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + + do { + let serializedEvent = try JSONEncoder().encode(serializableEvent) + self.id = Data(SHA256.hash(data: serializedEvent)).hex() + + let sig = try keyPair.schnorrSigner.signature(for: serializedEvent) + + guard keyPair.schnorrValidator.isValidSignature(sig, for: serializedEvent) else { + throw EventError.signingFailed + } + + self.signature = sig.rawRepresentation.hex() + } catch is EncodingError { + throw EventError.encodingFailed + } catch { + throw EventError.signingFailed + } + } +} diff --git a/Sources/NostrKit/EventFilter.swift b/Sources/NostrKit/EventFilter.swift new file mode 100644 index 0000000..e5da4a1 --- /dev/null +++ b/Sources/NostrKit/EventFilter.swift @@ -0,0 +1,64 @@ +import Foundation + +public struct EventFilter: Encodable { + let ids: [EventId]? + let authors: [String]? + let eventKinds: [EventKind]? + let tags: [String: [String]]? + let since: Timestamp? + let until: Timestamp? + let limit: Int? + + private enum CodingKeys: String, CodingKey { + case ids + case authors + case eventKinds = "kinds" + case since + case until + case limit + } + + private struct TagsCodingKeys: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { return nil } + } + + public init( + ids: [EventId]? = nil, + authors: [String]? = nil, + eventKinds: [EventKind]? = nil, + tags: [String: [String]]? = nil, + since: Timestamp? = nil, + until: Timestamp? = nil, + limit: Int? = nil + ) { + self.ids = ids + self.authors = authors + self.eventKinds = eventKinds + self.tags = tags + self.since = since + self.until = until + self.limit = limit + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(ids, forKey: .ids) + try container.encodeIfPresent(authors, forKey: .authors) + try container.encodeIfPresent(since, forKey: .since) + try container.encodeIfPresent(until, forKey: .until) + try container.encodeIfPresent(limit, forKey: .limit) + + var tagsContainer = encoder.container(keyedBy: TagsCodingKeys.self) + for (id, value) in tags ?? [:] { + try tagsContainer.encode(value, forKey: .init(stringValue: "#\(id)")!) + } + + } +} diff --git a/Sources/NostrKit/KeyPair.swift b/Sources/NostrKit/KeyPair.swift new file mode 100644 index 0000000..939875b --- /dev/null +++ b/Sources/NostrKit/KeyPair.swift @@ -0,0 +1,33 @@ +import Foundation +import secp256k1 + +public struct KeyPair { + typealias PrivateKey = secp256k1.Signing.PrivateKey + typealias PublicKey = secp256k1.Signing.PublicKey + + private let privateKey: PrivateKey + + var schnorrSigner: secp256k1.Signing.SchnorrSigner { + return privateKey.schnorr + } + + var schnorrValidator: secp256k1.Signing.SchnorrValidator { + return privateKey.publicKey.schnorr + } + + public var publicKey: String { + return Data(privateKey.publicKey.xonly.bytes).hex() + } + + public init() throws { + privateKey = try PrivateKey() + } + + public init(privateKey: String) throws { + self = try .init(privateKey: try Data(hex: privateKey)) + } + + public init(privateKey: Data) throws { + self.privateKey = try PrivateKey(rawRepresentation: privateKey) + } +} diff --git a/Sources/NostrKit/NostrKit.swift b/Sources/NostrKit/NostrKit.swift deleted file mode 100644 index f1ecac7..0000000 --- a/Sources/NostrKit/NostrKit.swift +++ /dev/null @@ -1,3 +0,0 @@ -public struct NostrKit { - public init() { } -} diff --git a/Sources/NostrKit/Relay.swift b/Sources/NostrKit/Relay.swift new file mode 100644 index 0000000..50b6b66 --- /dev/null +++ b/Sources/NostrKit/Relay.swift @@ -0,0 +1,85 @@ +import Foundation +import Starscream + +public enum RelayError: Error { + case cannotConnect(Error?) + case socketError(Error?) +} + +public protocol RelayDelegate: AnyObject { + func recevied(message: RelayMessage) +} + +public struct Relay { + public let url: URL + private let socket: WebSocket + + public weak var delegate: RelayDelegate? + public var eventCallback: ((RelayMessage) -> Void)? + + public init(url: URL, delegate: RelayDelegate? = nil, onEvent eventCallback: ((RelayMessage) -> Void)? = nil) { + self.url = url + self.delegate = delegate + self.eventCallback = eventCallback + + socket = WebSocket(url: url) + } + + public func connect() async throws { + return try await withCheckedThrowingContinuation { continuation in + socket.onText = { text in + guard let message = try? RelayMessage(text: text) else { return } + delegate?.recevied(message: message) + eventCallback?(message) + } + + socket.onDisconnect = { error in + continuation.resume(with: .failure(RelayError.cannotConnect(error))) + } + + socket.onConnect = { + socket.onDisconnect = { error in + continuation.resume(with: .failure(RelayError.socketError(error))) + } + + continuation.resume() + } + + socket.connect() + } + } + + public func send(event: Event) async throws { + try await send(message: ClientMessage.event(event)) + } + + public func subscribe(to subscription: Subscription) async throws { + try await send(message: ClientMessage.subscribe(subscription)) + } + + public func unsubscribe(from subscriptionId: SubscriptionId) async throws { + try await send(message: ClientMessage.unsubscribe(subscriptionId)) + } + + func send(message: ClientMessage) async throws { + return try await withCheckedThrowingContinuation { continuation in + print(try! message.string()) + socket.write(string: try! message.string()) { + continuation.resume() + } + } + } + + public func disconnect() async throws { + return try await withCheckedThrowingContinuation { continuation in + socket.onDisconnect = { error in + if let error = error { + continuation.resume(with: .failure(RelayError.socketError(error))) + } else { + continuation.resume() + } + } + socket.disconnect() + } + } +} diff --git a/Sources/NostrKit/RelayMessage.swift b/Sources/NostrKit/RelayMessage.swift new file mode 100644 index 0000000..ff19085 --- /dev/null +++ b/Sources/NostrKit/RelayMessage.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum RelayMessage: Decodable { + case event(SubscriptionId, Event) + case notice(String) + case other([String]) + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let messageType = try container.decode(String.self) + + switch messageType { + case "EVENT": + let subscriptionId = try container.decode(SubscriptionId.self) + let event = try container.decode(Event.self) + self = .event(subscriptionId, event) + case "NOTICE": + self = .notice(try container.decode(String.self)) + default: + let remainingItemsCount = (container.count ?? 1) - 1 + let remainingItems = try (0..