Skip to content

Commit

Permalink
add ability to create events, sign them, and send them to a relay (#46)
Browse files Browse the repository at this point in the history
* add ability to create events, sign them, and send them to a relay

* fixed: relays rejecting events due to incorrect data type for created_at
  • Loading branch information
bryanmontz authored Jul 19, 2023
1 parent 609570e commit bf0a080
Show file tree
Hide file tree
Showing 11 changed files with 221 additions and 51 deletions.
21 changes: 21 additions & 0 deletions Sources/NostrSDK/EventCreating.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// EventCreating.swift
//
//
// Created by Bryan Montz on 6/25/23.
//

import Foundation

public protocol EventCreating {}
public extension EventCreating {

/// Creates a text note event (kind 1) and signs it with the provided ``Keypair``
/// - Parameters:
/// - content: The content of the text note
/// - keypair: The Keypair to sign with
/// - Returns: The signed text note event
func textNote(withContent content: String, signedBy keypair: Keypair) throws -> NostrEvent {
try NostrEvent(kind: .textNote, content: content, signedBy: keypair)
}
}
56 changes: 56 additions & 0 deletions Sources/NostrSDK/EventSerializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// EventSerializer.swift
//
//
// Created by Bryan Montz on 6/25/23.
//

import Foundation

public enum EventSerializer {

/// Serializes properties of an event.
///
/// The serialization is done over the UTF-8 JSON-serialized string (with no white space or line breaks) of the following structure:
///
/// ```json
/// [
/// 0,
/// <pubkey, as a (lowercase) hex string>,
/// <created_at, as a number>,
/// <kind, as a number>,
/// <tags, as an array of arrays of non-null strings>,
/// <content, as a string>
/// ]
/// ```
///
/// See [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md#events-and-signatures).
public static func serializedEvent(withPubkey pubkey: String, createdAt: Int64, kind: Int, tags: [Tag], content: String) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes

let tagsString: String
if let tagsData = try? encoder.encode(tags) {
tagsString = String(decoding: tagsData, as: UTF8.self)
} else {
tagsString = "[]"
}

let contentString: String
if let contentData = try? encoder.encode(content) {
contentString = String(decoding: contentData, as: UTF8.self)
} else {
contentString = "\"\""
}
return "[0,\"\(pubkey)\",\(createdAt),\(kind),\(tagsString),\(contentString)]"
}

/// To obtain the event.id, we SHA256 the serialized event.
public static func identifierForEvent(withPubkey pubkey: String, createdAt: Int64, kind: Int, tags: [Tag], content: String) -> String {
serializedEvent(withPubkey: pubkey,
createdAt: createdAt,
kind: kind,
tags: tags,
content: content).data(using: .utf8)!.sha256.hexString
}
}
30 changes: 30 additions & 0 deletions Sources/NostrSDK/EventVerifying.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// EventVerifying.swift
//
//
// Created by Bryan Montz on 6/23/23.
//

import Foundation

public enum EventVerifyingError: Error, CustomStringConvertible {
case invalidId

public var description: String {
switch self {
case .invalidId: return "The id property did not match the calculated id."
}
}
}

public protocol EventVerifying: SignatureVerifying {}
public extension EventVerifying {

/// Verifies the identifier and the signature of a ``NostrEvent``
func verifyEvent(_ event: NostrEvent) throws {
guard event.id == event.calculatedId else {
throw EventVerifyingError.invalidId
}
try verifySignature(event.signature, for: event.id, withPublicKey: event.pubkey)
}
}
78 changes: 42 additions & 36 deletions Sources/NostrSDK/NostrEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public struct NostrEvent: Codable {
public let pubkey: String

/// unix timestamp in seconds
public let createdAt: TimeInterval
public let createdAt: Int64

/// integer
public let kind: EventKind
Expand All @@ -43,44 +43,50 @@ public struct NostrEvent: Codable {
case signature = "sig"
}

init(id: String, pubkey: String, createdAt: Int64, kind: EventKind, tags: [Tag], content: String, signature: String) {
self.id = id
self.pubkey = pubkey
self.createdAt = createdAt
self.kind = kind
self.tags = tags
self.content = content
self.signature = signature
}

init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws {
self.kind = kind
self.content = content
self.tags = tags
self.createdAt = createdAt
pubkey = keypair.publicKey.hex
id = EventSerializer.identifierForEvent(withPubkey: keypair.publicKey.hex,
createdAt: createdAt,
kind: kind.rawValue,
tags: tags,
content: content)
signature = try keypair.privateKey.signatureForContent(id)
}

/// the date the event was created
public var createdDate: Date {
Date(timeIntervalSince1970: createdAt)
Date(timeIntervalSince1970: TimeInterval(createdAt))
}

/// the event serialized, so that it can be signed
public var serialized: String {
EventSerializer.serializedEvent(withPubkey: pubkey,
createdAt: createdAt,
kind: kind.rawValue,
tags: tags,
content: content)
}

/// the serialized event
///
/// To obtain the `event.id`, we sha256 the serialized event. The serialization is done over the UTF-8 JSON-serialized string (with no white space or line breaks) of the following structure:
///
/// ```json
/// [
/// 0,
/// <pubkey, as a (lowercase) hex string>,
/// <created_at, as a number>,
/// <kind, as a number>,
/// <tags, as an array of arrays of non-null strings>,
/// <content, as a string>
/// ]
/// ```
///
/// See [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md#events-and-signatures).
public var serializedForSigning: String {
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes

let tagsString: String
if let tagsData = try? encoder.encode(tags) {
tagsString = String(decoding: tagsData, as: UTF8.self)
} else {
tagsString = "[]"
}

let contentString: String
if let contentData = try? encoder.encode(content) {
contentString = String(decoding: contentData, as: UTF8.self)
} else {
contentString = "\"\""
}
return "[0,\"\(pubkey)\",\(Int64(createdAt)),\(kind.rawValue),\(tagsString),\(contentString)]"
/// the event.id calculated as a SHA256 of the serialized event. See ``EventSerializer``.
public var calculatedId: String {
EventSerializer.identifierForEvent(withPubkey: pubkey,
createdAt: createdAt,
kind: kind.rawValue,
tags: tags,
content: content)
}
}
18 changes: 17 additions & 1 deletion Sources/NostrSDK/Relay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public protocol RelayDelegate: AnyObject {
}

/// An object that communicates with a relay.
public final class Relay: ObservableObject {
public final class Relay: ObservableObject, EventVerifying {

/// Constants indicating the current state of the relay.
public enum State: Equatable {
Expand Down Expand Up @@ -214,4 +214,20 @@ public final class Relay: ObservableObject {
}
send(request: request)
}

/// Publishes an event to the relay.
/// - Parameter event: The ``NostrEvent`` to publish
public func publishEvent(_ event: NostrEvent) throws {
guard state == .connected else {
throw RelayRequestError.notConnected
}

try verifyEvent(event)

guard let request = RelayRequest.event(event).encoded else {
throw RelayRequestError.invalidRequest
}

send(request: request)
}
}
2 changes: 1 addition & 1 deletion Sources/NostrSDK/SignatureVerifying.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public extension SignatureVerifying {
/// Throws an error if there is a problem with the input parameters or if the signature is invalid. See ``SignatureVerifyingError``.
///
/// If this function does not throw an error, then the signature has been successfully verified.
func verifySignature(_ signature: String, for message: String, with publicKey: String) throws {
func verifySignature(_ signature: String, for message: String, withPublicKey publicKey: String) throws {
guard let signatureData = signature.hexadecimalData, signatureData.count == 64 else {
throw SignatureVerifyingError.unexpectedSignatureLength
}
Expand Down
9 changes: 3 additions & 6 deletions Tests/NostrSDKTests/ContentSigningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,15 @@ import XCTest
final class ContentSigningTests: XCTestCase, FixtureLoading, ContentSigning, SignatureVerifying {

func testSignEvent() throws {
let privateKey = try XCTUnwrap(PrivateKey(nsec: "nsec163p74rxf58ndvav7ck8axx39qmt6dvwjgm8z98ckanenzf3mpjyq6875fz"))
let publicKey = try XCTUnwrap(PublicKey(npub: "npub1n9rljevamqxrdqjq9dsj74z8u2pynxtlkdcf2qxr9fv9avyhwdqqf6w3at"))

let event: NostrEvent = try decodeFixture(filename: "test_event")

let calculatedId = event.serializedForSigning.data(using: .utf8)!.sha256.hexString
let calculatedId = event.calculatedId
XCTAssertEqual(calculatedId, event.id)

// sign a few times and verify the signatures
for _ in 1...4 {
let signature = try privateKey.signatureForContent(calculatedId)
try verifySignature(signature, for: calculatedId, with: publicKey.hex)
let signature = try Keypair.test.privateKey.signatureForContent(calculatedId)
try verifySignature(signature, for: calculatedId, withPublicKey: Keypair.test.publicKey.hex)
}
}
}
25 changes: 25 additions & 0 deletions Tests/NostrSDKTests/EventCreatingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// EventCreatingTests.swift
//
//
// Created by Bryan Montz on 6/25/23.
//

import Foundation
import NostrSDK
import XCTest

final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying {

func testCreateSignedTextNote() throws {
let note = try textNote(withContent: "Hello world!",
signedBy: Keypair.test)

XCTAssertEqual(note.kind, .textNote)
XCTAssertEqual(note.content, "Hello world!")
XCTAssertEqual(note.pubkey, Keypair.test.publicKey.hex)
XCTAssertEqual(note.tags, [])

try verifyEvent(note)
}
}
6 changes: 3 additions & 3 deletions Tests/NostrSDKTests/EventSerializationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ final class EventSerializationTests: XCTestCase, FixtureLoading {
func testEventSerialization() throws {
let event: NostrEvent = try decodeFixture(filename: "simple_note")
let idData = try XCTUnwrap(event.id.hexadecimalData)
let calculated = event.serializedForSigning.data(using: .utf8)!.sha256
XCTAssertEqual(idData, calculated)
let calculatedId = event.calculatedId.hexadecimalData
XCTAssertEqual(idData, calculatedId)
}

func testEventSerializationWithTags() throws {
let event: NostrEvent = try decodeFixture(filename: "text_note")
let idData = try XCTUnwrap(event.id.hexadecimalData)
let calculatedId = event.serializedForSigning.data(using: .utf8)!.sha256
let calculatedId = event.calculatedId.hexadecimalData
XCTAssertEqual(idData, calculatedId)
}
}
8 changes: 4 additions & 4 deletions Tests/NostrSDKTests/EventVerifyingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ final class EventVerifyingTests: XCTestCase, SignatureVerifying {
let signature = "4b28e1f4a27e152efbd9d9ec677350e7a59ee87c5165161f058ce809ca63b67e840b85c2b45d0d547062649d826bdb1c22102481bc3d3dc123e9570fc19e2b0a"
let message = "79dbf85121617ef657d9baa303b9887cd39c8ce22facb367092d3ceb3c2bf76d"
let publicKey = "07ecf9838136fe430fac43fa0860dbc62a0aac0729c5a33df1192ce75e330c9f"
XCTAssertNoThrow(try verifySignature(signature, for: message, with: publicKey))
XCTAssertNoThrow(try verifySignature(signature, for: message, withPublicKey: publicKey))
}

func testVerifyUnexpectedSignatureLength() {
let signature = "b640b85c2b45d0d547062649d826bdb1c22102481bc3d3dc123e9570fc19e2b0a"
let message = "79dbf85121617ef657d9baa303b9887cd39c8ce22facb367092d3ceb3c2bf76d"
let publicKey = "07ecf9838136fe430fac43fa0860dbc62a0aac0729c5a33df1192ce75e330c9f"

XCTAssertThrowsError(try verifySignature(signature, for: message, with: publicKey)) { error in
XCTAssertThrowsError(try verifySignature(signature, for: message, withPublicKey: publicKey)) { error in
XCTAssertEqual(error as? SignatureVerifyingError, SignatureVerifyingError.unexpectedSignatureLength)
}
}
Expand All @@ -33,7 +33,7 @@ final class EventVerifyingTests: XCTestCase, SignatureVerifying {
let message = "79dbf85121617ef657d9baa303b9887cd39c8ce22facb367092d3ceb3c2bf76d"
let publicKey = "07ecf9838136fac0729c5a33df1192ce75e330c9f"

XCTAssertThrowsError(try verifySignature(signature, for: message, with: publicKey)) { error in
XCTAssertThrowsError(try verifySignature(signature, for: message, withPublicKey: publicKey)) { error in
XCTAssertEqual(error as? SignatureVerifyingError, SignatureVerifyingError.unexpectedPublicKeyLength)
}
}
Expand All @@ -43,7 +43,7 @@ final class EventVerifyingTests: XCTestCase, SignatureVerifying {
let message = "79dbf85121617ef657d9baa303b9887cd39c8ce22facb367092d3ceb3c2bf76d"
let publicKey = "07ecf9838136fe430fac43fa0860dbc62a0aac0729c5a33df1192ce75e330c9f"

XCTAssertThrowsError(try verifySignature(signature, for: message, with: publicKey)) { error in
XCTAssertThrowsError(try verifySignature(signature, for: message, withPublicKey: publicKey)) { error in
XCTAssertEqual(error as? SignatureVerifyingError, SignatureVerifyingError.invalidSignature)
}
}
Expand Down
19 changes: 19 additions & 0 deletions Tests/NostrSDKTests/Helpers/Keypair+Test.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// Keypair+Test.swift
//
//
// Created by Bryan Montz on 6/23/23.
//

import Foundation
import NostrSDK

extension Keypair {

/// A ``Keypair`` for use in unit tests
///
/// The corresponding npub is npub1n9rljevamqxrdqjq9dsj74z8u2pynxtlkdcf2qxr9fv9avyhwdqqf6w3at.
static var test: Keypair {
Keypair(nsec: "nsec163p74rxf58ndvav7ck8axx39qmt6dvwjgm8z98ckanenzf3mpjyq6875fz")!
}
}

0 comments on commit bf0a080

Please sign in to comment.