Skip to content

Commit

Permalink
Add support for NIP-59 gift wraps
Browse files Browse the repository at this point in the history
  • Loading branch information
tyiu committed May 18, 2024
1 parent 429fcb9 commit 8dec305
Show file tree
Hide file tree
Showing 12 changed files with 460 additions and 8 deletions.
2 changes: 1 addition & 1 deletion Sources/NostrSDK/EventCreating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ enum EventCreatingError: Error {
case invalidInput
}

public protocol EventCreating: DirectMessageEncrypting, RelayURLValidating {}
public protocol EventCreating: DirectMessageEncrypting, NIP44v2Encrypting, RelayURLValidating {}
23 changes: 23 additions & 0 deletions Sources/NostrSDK/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,29 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
/// See [NIP-25 - Reactions](https://github.com/nostr-protocol/nips/blob/master/25.md)
case reaction

/// This kind of event seals a `rumor` with the sender's private key.
/// A rumor is the same thing as an unsigned event. Any event kind can be made a rumor by removing the signature.
/// The seal is always encrypted to a receiver's pubkey but there is no p tag pointing to the receiver.
/// There is no way to know who the rumor is for without the receiver's or the sender's private key.
/// The only public information in this event is who is signing it.
///
/// See [NIP-59 - Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md).
case seal

/// This kind of note is used to signal to followers that another event is worth reading.
///
/// > Note: The reposted event can be any kind of event other than a kind 1 text note.
/// See [NIP-18](https://github.com/nostr-protocol/nips/blob/master/18.md#nip-18).
case genericRepost

/// This kind of event wraps a `seal` event.
/// The wrapped seal is always encrypted to a receiver's pubkey using a random, one-time-use private key.
/// The gift wrap event tags should include any information needed to route the event to its intended recipient,
/// including the recipient's `p` tag or [NIP-13 Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md).
///
/// See [NIP-59 - Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md).
case giftWrap

/// This kind of note is used to report users or other notes for spam, illegal, and explicit content.
///
/// See [NIP-56](https://github.com/nostr-protocol/nips/blob/b4cdc1a73d415c79c35655fa02f5e55cd1f2a60c/56.md#nip-56).
Expand Down Expand Up @@ -108,7 +125,9 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
.deletion,
.repost,
.reaction,
.seal,
.genericRepost,
.giftWrap,
.report,
.muteList,
.bookmarksList,
Expand Down Expand Up @@ -137,7 +156,9 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
case .deletion: return 5
case .repost: return 6
case .reaction: return 7
case .seal: return 13
case .genericRepost: return 16
case .giftWrap: return 1059
case .report: return 1984
case .muteList: return 10000
case .bookmarksList: return 10003
Expand All @@ -160,7 +181,9 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
case .deletion: return DeletionEvent.self
case .repost: return TextNoteRepostEvent.self
case .reaction: return ReactionEvent.self
case .seal: return SealEvent.self
case .genericRepost: return GenericRepostEvent.self
case .giftWrap: return GiftWrapEvent.self
case .report: return ReportEvent.self
case .muteList: return MuteListEvent.self
case .bookmarksList: return BookmarksListEvent.self
Expand Down
11 changes: 8 additions & 3 deletions Sources/NostrSDK/EventVerifying.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import Foundation

public enum EventVerifyingError: Error, CustomStringConvertible {
case invalidId

case unsignedEvent

public var description: String {
switch self {
case .invalidId: return "The id property did not match the calculated id."
case .invalidId: return "The id property did not match the calculated id."
case .unsignedEvent: return "The event is not signed."
}
}
}
Expand All @@ -25,6 +27,9 @@ public extension EventVerifying {
guard event.id == event.calculatedId else {
throw EventVerifyingError.invalidId
}
try verifySignature(event.signature, for: event.id, withPublicKey: event.pubkey)
guard let signature = event.signature else {
throw EventVerifyingError.unsignedEvent
}
try verifySignature(signature, for: event.id, withPublicKey: event.pubkey)
}
}
125 changes: 125 additions & 0 deletions Sources/NostrSDK/Events/GiftWrap/GiftWrapEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//
// GiftWrapEvent.swift
//
//
// Created by Terry Yiu on 5/11/24.
//

import Foundation

/// An event that gift wraps a `SealEvent`.
/// The wrapped seal is always encrypted to a receiver's pubkey using a random, one-time-use private key.
/// The gift wrap event tags should include any information needed to route the event to its intended recipient,
/// including the recipient's `p` tag or [NIP-13 Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md).
///
/// The underlying `SealEvent` or rumor should not be broadcast by themselves to relays without this gift wrap.
/// This gift wrap event should be broadcast to only the recipient's relays
///
/// See [NIP-59 - Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md).
public final class GiftWrapEvent: NostrEvent, NIP44v2Encrypting {
public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

@available(*, unavailable, message: "This initializer is unavailable for this class.")
override init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws {
try super.init(kind: kind, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

public init(content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800)), signedBy keypair: Keypair) throws {
try super.init(kind: .seal, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

/// Unwraps the content of the gift wrap event and decrypts it into a ``SealEvent``.
/// - Parameters:
/// - privateKey: The ``PrivateKey`` to decrypt the content.
/// - Returns: The ``SealEvent``.
public func unwrap(privateKey: PrivateKey) throws -> SealEvent {
guard let wrapperPublicKey = PublicKey(hex: pubkey) else {
throw GiftWrapError.pubkeyInvalid
}

guard let unwrappedSeal = try? decrypt(payload: content, privateKeyA: privateKey, publicKeyB: wrapperPublicKey) else {
throw GiftWrapError.decryptionFailed
}

guard let sealJSONData = unwrappedSeal.data(using: .utf8) else {
throw GiftWrapError.utf8EncodingFailed
}

guard let sealEvent = try? JSONDecoder().decode(SealEvent.self, from: sealJSONData) else {
throw GiftWrapError.jsonDecodingFailed
}

return sealEvent
}

/// Unseals the content of this seal event into a decrypted rumor.
/// - Parameters:
/// - privateKey: The `PrivateKey` to decrypt the rumor.
/// - Returns: The decrypted ``NostrEvent`` rumor, where its `signature` is `nil`.
public func unseal(privateKey: PrivateKey) throws -> NostrEvent? {
let sealEvent = try unwrap(privateKey: privateKey)
return try sealEvent.unseal(privateKey: privateKey)
}
}

public enum GiftWrapError: Error {
case decryptionFailed
case jsonDecodingFailed
case keypairGenerationFailed
case pubkeyInvalid
case utf8EncodingFailed
}

public extension EventCreating {

/// Creates a ``GiftWrapEvent`` that takes a rumor, an unsigned ``NostrEvent``, and seals it in a signed ``SealEvent``, and then wraps that seal encrypted in the content of the gift wrap.
///
/// - Parameters:
/// - withRumor: a ``NostrEvent`` that is not signed.
/// - toRecipient: the ``PublicKey`` of the receiver of the event. This pubkey will automatically be added as a tag to the ``GiftWrapEvent``.
/// - tags: the list of tags to add to the ``GiftWrapEvent`` in addition to the pubkey tag from `toRecipient`. This list should include any information needed to route the event to its intended recipient, such as [NIP-13 Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md).
/// - createdAt: the creation timestamp of the seal. Note that this timestamp SHOULD be tweaked to thwart time-analysis attacks. Note that some relays don't serve events dated in the future, so all timestamps SHOULD be in the past. By default, if `createdAt` is not provided, a random timestamp within 2 days in the past will be chosen.
/// - keypair: The real ``Keypair`` to sign the seal with. Note that a different random one-time use key is used to sign the gift wrap.
func giftWrap(
withRumor rumor: NostrEvent,
toRecipient recipient: PublicKey,
tags: [Tag] = [],
createdAt: Int64 = Int64(Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800)),
signedBy keypair: Keypair
) throws -> GiftWrapEvent {
let seal = try seal(withRumor: rumor, toRecipient: recipient, signedBy: keypair)
return try giftWrap(withSeal: seal, toRecipient: recipient, tags: tags, createdAt: createdAt, signedBy: keypair)
}

/// Creates a ``GiftWrapEvent`` that takes a signed``SealEvent``, and then wraps that seal encrypted in the content of the gift wrap.
///
/// - Parameters:
/// - withSeal: a signed ``SealEvent``.
/// - toRecipient: the ``PublicKey`` of the receiver of the event.
/// - tags: the list of tags
/// - createdAt: the creation timestamp of the seal. Note that this timestamp SHOULD be tweaked to thwart time-analysis attacks. Note that some relays don't serve events dated in the future, so all timestamps SHOULD be in the past. By default, if `createdAt` is not provided, a random timestamp within 2 days in the past will be chosen.
/// - keypair: The real ``Keypair`` to sign the seal with. Note that a different random one-time use key is used to sign the gift wrap.
func giftWrap(
withSeal seal: SealEvent,
toRecipient recipient: PublicKey,
tags: [Tag] = [],
createdAt: Int64 = Int64(Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800)),
signedBy keypair: Keypair
) throws -> GiftWrapEvent {
let jsonData = try JSONEncoder().encode(seal)
guard let stringifiedJSON = String(data: jsonData, encoding: .utf8) else {
throw EventCreatingError.invalidInput
}

guard let randomKeypair = Keypair() else {
throw GiftWrapError.keypairGenerationFailed
}

let combinedTags = [Tag(name: .pubkey, value: recipient.hex)] + tags

let encryptedSeal = try encrypt(plaintext: stringifiedJSON, privateKeyA: randomKeypair.privateKey, publicKeyB: recipient)
return try GiftWrapEvent(content: encryptedSeal, tags: combinedTags, createdAt: createdAt, signedBy: randomKeypair)
}
}
96 changes: 96 additions & 0 deletions Sources/NostrSDK/Events/GiftWrap/SealEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// SealEvent.swift
//
//
// Created by Terry Yiu on 5/5/24.
//

import Foundation

/// An event that seals a `rumor` with the sender's private key.
/// A rumor is the same thing as an unsigned event. Any event kind can be made a rumor by removing the signature.
/// The seal is always encrypted to a receiver's pubkey but there is no p tag pointing to the receiver.
/// There is no way to know who the rumor is for without the receiver's or the sender's private key.
/// The only public information in this event is who is signing it.
///
/// This event should never be broadcasted by itself to relays.
/// It should be be wrapped in a ``GiftWrapEvent`` before broadcasting it to the recipient's relays.
///
/// See [NIP-59 - Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md).
public final class SealEvent: NostrEvent, NIP44v2Encrypting {
public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

@available(*, unavailable, message: "This initializer is unavailable for this class.")
override init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws {
try super.init(kind: kind, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

public init(content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800)), signedBy keypair: Keypair) throws {
try super.init(kind: .seal, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

/// Unseals the content of this seal event into a decrypted rumor.
/// - Parameters:
/// - privateKey: The ``PrivateKey`` to decrypt the rumor.
/// - Returns: The decrypted ``NostrEvent`` rumor, where its `signature` is `nil`.
public func unseal(privateKey: PrivateKey) throws -> NostrEvent {
guard let authorPublicKey = PublicKey(hex: pubkey) else {
throw SealEventError.pubkeyInvalid
}

guard let unsealedRumor = try? decrypt(payload: content, privateKeyA: privateKey, publicKeyB: authorPublicKey) else {
throw SealEventError.decryptionFailed
}

guard let rumorJSONData = unsealedRumor.data(using: .utf8) else {
throw SealEventError.utf8EncodingFailed
}

guard let rumor = try? JSONDecoder().decode(NostrEvent.self, from: rumorJSONData) else {
throw SealEventError.jsonDecodingFailed
}

return rumor
}
}

public enum SealEventError: Error {
case decryptionFailed
case jsonDecodingFailed
case pubkeyInvalid
case sealSignedEvent
case utf8EncodingFailed
}

public extension EventCreating {

/// Creates a `SealEvent` that encrypts a rumor with the sender's private key and receiver's public key.
/// There is no p tag pointing to the receiver. There is no way to know who the rumor is for without the receiver's or the sender's private key.
/// The only public information in this event is who is signing it.
///
/// - Parameters:
/// - withRumor: a ``NostrEvent`` that is not signed.
/// - toRecipient: the ``PublicKey`` of the receiver of the event.
/// - createdAt: the creation timestamp of the seal. Note that this timestamp SHOULD be tweaked to thwart time-analysis attacks. Note that some relays don't serve events dated in the future, so all timestamps SHOULD be in the past. By default, if `createdAt` is not provided, a random timestamp within 2 days in the past will be chosen.
/// - keypair: The ``Keypair`` to sign with.
func seal(
withRumor rumor: NostrEvent,
toRecipient recipient: PublicKey,
createdAt: Int64 = Int64(Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800)),
signedBy keypair: Keypair
) throws -> SealEvent {
guard rumor.isRumor else {
throw SealEventError.sealSignedEvent
}

let jsonData = try JSONEncoder().encode(rumor)
guard let stringifiedJSON = String(data: jsonData, encoding: .utf8) else {
throw EventCreatingError.invalidInput
}

let encryptedRumor = try encrypt(plaintext: stringifiedJSON, privateKeyA: keypair.privateKey, publicKeyB: recipient)
return try SealEvent(content: encryptedRumor, createdAt: createdAt, signedBy: keypair)
}
}
18 changes: 14 additions & 4 deletions Sources/NostrSDK/Events/NostrEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public class NostrEvent: Codable, Equatable, Hashable {
public let content: String

/// 64-byte hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field
public let signature: String
public let signature: String?

private enum CodingKeys: String, CodingKey {
case id
Expand All @@ -52,7 +52,7 @@ public class NostrEvent: Codable, Equatable, Hashable {
case signature = "sig"
}

init(id: String, pubkey: String, createdAt: Int64, kind: EventKind, tags: [Tag], content: String, signature: String) {
init(id: String, pubkey: String, createdAt: Int64, kind: EventKind, tags: [Tag], content: String, signature: String?) {
self.id = id
self.pubkey = pubkey
self.createdAt = createdAt
Expand All @@ -61,7 +61,7 @@ public class NostrEvent: Codable, Equatable, Hashable {
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
Expand Down Expand Up @@ -108,7 +108,17 @@ public class NostrEvent: Codable, Equatable, Hashable {
tags: tags,
content: content)
}


/// The event is a rumor if it is an unsigned event, where `signature` is `nil`.
public var isRumor: Bool {
signature == nil
}

/// Creates a copy of this event and makes it into a rumor ``NostrEvent``, where `signature` is `nil`.
public var rumor: NostrEvent {
NostrEvent(id: id, pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content, signature: nil)
}

/// All tags with the provided name.
public func allTags(withTagName tagName: TagName) -> [Tag] {
tags.filter { $0.name == tagName.rawValue }
Expand Down
Loading

0 comments on commit 8dec305

Please sign in to comment.