damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 84cfeb16048c6387a38eaac5d1fbf1a1d1762a13
parent 4c37bfc128a6d3feab1c6fd792f4c88d35ccb25d
Author: Charlie Fish <contact@charlie.fish>
Date:   Sun, 24 Dec 2023 14:22:25 -0700

nip42: add initial relay auth support

Lightning-Invoice: lnbc1pjcpaakpp5gjs4f626hf8w6slx84xz3wwhlf309z503rjutckdxv6wwg5ldavsdqqcqzpgxqrrs0fppqjaxxw43p7em4g59vedv7pzl76kt0qyjfsp5qcp9de7a7t8h6zs5mcssfaqp4exrnkehqtg2hf0ary3z5cjnasvs9qyyssq55523e4h3cazhkv7f8jqf5qp0n8spykls49crsu5t3m636u3yj4qdqjkdl2nxf6jet5t2r2pfrxmm8rjpqjd3ylrzqq89m4gqt5l6ycqf92c7h
Closes: https://github.com/damus-io/damus/issues/940
Signed-off-by: Charlie Fish <contact@charlie.fish>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Added: Add NIP-42 relay auth support

Diffstat:
MREADME.md | 2++
Mdamus.xcodeproj/project.pbxproj | 20++++++++++++++++++++
Mdamus/ContentView.swift | 7++++++-
Mdamus/Models/EventsModel.swift | 2++
Mdamus/Models/FollowersModel.swift | 2++
Mdamus/Models/HomeModel.swift | 2++
Mdamus/Models/ProfileModel.swift | 2++
Mdamus/Models/SearchHomeModel.swift | 4++++
Mdamus/Models/SearchModel.swift | 3+++
Mdamus/Models/ZapsModel.swift | 2++
Adamus/Nostr/NostrAuth.swift | 14++++++++++++++
Mdamus/Nostr/NostrRequest.swift | 5++++-
Mdamus/Nostr/NostrResponse.swift | 15++++++++++++++-
Mdamus/Nostr/Relay.swift | 23++++++++++++++++++++++-
Mdamus/Nostr/RelayConnection.swift | 15++++++++++++++-
Mdamus/Nostr/RelayPool.swift | 40+++++++++++++++++++++++++++++++++++++---
Adamus/Notify/ReconnectRelaysNotify.swift | 26++++++++++++++++++++++++++
Mdamus/Views/Onboarding/SuggestedUsersViewModel.swift | 3+++
Adamus/Views/Relays/Detail/RelayAuthenticationDetail.swift | 35+++++++++++++++++++++++++++++++++++
Mdamus/Views/Relays/RelayDetailView.swift | 17++++++++++++++---
Mdamus/Views/SaveKeysView.swift | 2++
Mdamus/damusApp.swift | 3+++
AdamusTests/AuthIntegrationTests.swift | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MdamusTests/Mocking/MockDamusState.swift | 13+------------
MdamusTests/RequestTests.swift | 29++++++++++++++++++++++++++++-
AdamusTests/Util/NdbExtensions.swift | 26++++++++++++++++++++++++++
Mnostrdb/nostrdb.c | 11+++++++++++
Mnostrdb/nostrdb.h | 1+
Mnostrscript/NostrScript.swift | 5++++-
29 files changed, 484 insertions(+), 25 deletions(-)

diff --git a/README.md b/README.md @@ -16,12 +16,14 @@ damus implements the following [Nostr Implementation Possibilities][nips] - [NIP-08: Mentions][nip08] - [NIP-10: Reply conventions][nip10] - [NIP-12: Generic tag queries (hashtags)][nip12] +- [NIP-42: Authentication of clients to relays][nip42] [nips]: https://github.com/nostr-protocol/nips [nip01]: https://github.com/nostr-protocol/nips/blob/master/01.md [nip08]: https://github.com/nostr-protocol/nips/blob/master/08.md [nip10]: https://github.com/nostr-protocol/nips/blob/master/10.md [nip12]: https://github.com/nostr-protocol/nips/blob/master/12.md +[nip42]: https://github.com/nostr-protocol/nips/blob/master/42.md ## Getting Started on Damus diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -420,6 +420,11 @@ 9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C83F89229A937B900136C08 /* TextViewWrapper.swift */; }; 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; }; ADFE73552AD4793100EC7326 /* QRScanNSECView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */; }; + B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B501062C2B363036003874F5 /* AuthIntegrationTests.swift */; }; + B57B4C622B312BD700A232C0 /* ReconnectRelaysNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B4C612B312BD700A232C0 /* ReconnectRelaysNotify.swift */; }; + B57B4C642B312BFA00A232C0 /* RelayAuthenticationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B4C632B312BFA00A232C0 /* RelayAuthenticationDetail.swift */; }; + B57B4C662B312C3700A232C0 /* NostrAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B4C652B312C3700A232C0 /* NostrAuth.swift */; }; + B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B4D1422B37D47600844320 /* NdbExtensions.swift */; }; BA37598A2ABCCDE40018D73B /* ImageResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759892ABCCDE30018D73B /* ImageResizer.swift */; }; BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */; }; BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */; }; @@ -1237,6 +1242,11 @@ 9C83F89229A937B900136C08 /* TextViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewWrapper.swift; sourceTree = "<group>"; }; 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachMediaUtility.swift; sourceTree = "<group>"; }; ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScanNSECView.swift; sourceTree = "<group>"; }; + B501062C2B363036003874F5 /* AuthIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthIntegrationTests.swift; sourceTree = "<group>"; usesTabs = 0; }; + B57B4C612B312BD700A232C0 /* ReconnectRelaysNotify.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReconnectRelaysNotify.swift; sourceTree = "<group>"; }; + B57B4C632B312BFA00A232C0 /* RelayAuthenticationDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelayAuthenticationDetail.swift; sourceTree = "<group>"; }; + B57B4C652B312C3700A232C0 /* NostrAuth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NostrAuth.swift; sourceTree = "<group>"; }; + B5B4D1422B37D47600844320 /* NdbExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbExtensions.swift; sourceTree = "<group>"; usesTabs = 0; }; BA3759892ABCCDE30018D73B /* ImageResizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResizer.swift; sourceTree = "<group>"; }; BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCaptureProcessor.swift; sourceTree = "<group>"; }; BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCaptureProcessor.swift; sourceTree = "<group>"; }; @@ -1899,6 +1909,7 @@ D7FF93FF2AC7AC5200FD969D /* RelayURL.swift */, D798D22B2B086C7400234419 /* NostrEvent+.swift */, D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */, + B57B4C652B312C3700A232C0 /* NostrAuth.swift */, ); path = Nostr; sourceTree = "<group>"; @@ -2040,6 +2051,7 @@ isa = PBXGroup; children = ( 4C9B0DED2A65A75F00CBDA21 /* AttrStringTestExtensions.swift */, + B5B4D1422B37D47600844320 /* NdbExtensions.swift */, ); path = Util; sourceTree = "<group>"; @@ -2076,6 +2088,7 @@ 4C12536B2A76D4B00004F4B8 /* RepostedNotify.swift */, 4C4E137A2A76D5FB00BDD832 /* MuteThreadNotify.swift */, 4C4E137C2A76D63600BDD832 /* UnmuteThreadNotify.swift */, + B57B4C612B312BD700A232C0 /* ReconnectRelaysNotify.swift */, ); path = Notify; sourceTree = "<group>"; @@ -2353,6 +2366,7 @@ D71DC1EB2A9129C3006E207C /* PostViewTests.swift */, D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */, D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */, + B501062C2B363036003874F5 /* AuthIntegrationTests.swift */, ); path = damusTests; sourceTree = "<group>"; @@ -2381,6 +2395,7 @@ isa = PBXGroup; children = ( 4CE879542996BAB900F758CC /* RelayPaidDetail.swift */, + B57B4C632B312BFA00A232C0 /* RelayAuthenticationDetail.swift */, ); path = Detail; sourceTree = "<group>"; @@ -2831,6 +2846,7 @@ 4C75EFB728049D990006080F /* RelayPool.swift in Sources */, F757933A29D7AECD007DEAC1 /* ImagePicker.swift in Sources */, 4CF0ABEE29844B5500D66079 /* AnyEncodable.swift in Sources */, + B57B4C662B312C3700A232C0 /* NostrAuth.swift in Sources */, 4CB8838D296F710400DC99E7 /* Reposted.swift in Sources */, 4C3EA67728FF7A9800C48A62 /* talstr.c in Sources */, 4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */, @@ -2884,6 +2900,7 @@ 4C216F362870A9A700040376 /* InputDismissKeyboard.swift in Sources */, 4C8D1A6F29F31E5000ACDF75 /* FriendsButton.swift in Sources */, 3A5E47C52A4A6CF400C0D090 /* Trie.swift in Sources */, + B57B4C642B312BFA00A232C0 /* RelayAuthenticationDetail.swift in Sources */, 4C216F382871EDE300040376 /* DirectMessageModel.swift in Sources */, BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */, 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, @@ -3187,6 +3204,7 @@ 4C75EFB528049D790006080F /* Relay.swift in Sources */, 4CEE2AF1280B216B00AB5EEF /* EventDetailView.swift in Sources */, 4CC7AAFA297F64AC00430951 /* EventMenu.swift in Sources */, + B57B4C622B312BD700A232C0 /* ReconnectRelaysNotify.swift in Sources */, E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */, 4C75EFBB2804A34C0006080F /* ProofOfWork.swift in Sources */, 4C3AC7A52836987600E1F516 /* MainTabView.swift in Sources */, @@ -3220,6 +3238,7 @@ 4C8D00D429E3C5D40036AF10 /* NIP19Tests.swift in Sources */, 3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */, 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */, + B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */, 3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */, D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */, 4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */, @@ -3235,6 +3254,7 @@ 75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */, F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */, 3A5E47C72A4A76C800C0D090 /* TrieTests.swift in Sources */, + B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */, 4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */, D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */, 4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -458,6 +458,9 @@ struct ContentView: View { break } } + .onReceive(handle_notify(.disconnect_relays)) { () in + damus_state.pool.disconnect() + } .onChange(of: scenePhase) { (phase: ScenePhase) in switch phase { case .background: @@ -627,7 +630,7 @@ struct ContentView: View { guard let ndb = mndb else { return } - let pool = RelayPool(ndb: ndb) + let pool = RelayPool(ndb: ndb, keypair: keypair) let model_cache = RelayModelCache() let relay_filters = RelayFilters(our_pubkey: pubkey) let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey) @@ -914,6 +917,8 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St } case .notice: break + case .auth: + break } } diff --git a/damus/Models/EventsModel.swift b/damus/Models/EventsModel.swift @@ -63,6 +63,8 @@ class EventsModel: ObservableObject { break case .ok: break + case .auth: + break case .eose: let txn = NdbTxn(ndb: self.state.ndb) load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn) diff --git a/damus/Models/FollowersModel.swift b/damus/Models/FollowersModel.swift @@ -91,6 +91,8 @@ class FollowersModel: ObservableObject { case .ok: break + case .auth: + break } } } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -446,6 +446,8 @@ class HomeModel { case .ok: break + case .auth: + break } } diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift @@ -134,6 +134,8 @@ class ProfileModel: ObservableObject, Equatable { } progress += 1 break + case .auth: + break } } } diff --git a/damus/Models/SearchHomeModel.swift b/damus/Models/SearchHomeModel.swift @@ -88,6 +88,8 @@ class SearchHomeModel: ObservableObject { } break + case .auth: + break } } } @@ -159,6 +161,8 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: String, break case .notice: break + case .auth: + break } } diff --git a/damus/Models/SearchModel.swift b/damus/Models/SearchModel.swift @@ -130,6 +130,9 @@ func handle_subid_event(pool: RelayPool, relay_id: String, ev: NostrConnectionEv case .eose(let subid): return (subid, true) + + case .auth: + return (nil, false) } } } diff --git a/damus/Models/ZapsModel.swift b/damus/Models/ZapsModel.swift @@ -66,6 +66,8 @@ class ZapsModel: ObservableObject { } self.state.add_zap(zap: .zap(zap)) + case .auth: + break } diff --git a/damus/Nostr/NostrAuth.swift b/damus/Nostr/NostrAuth.swift @@ -0,0 +1,14 @@ +// +// NostrAuth.swift +// damus +// +// Created by Charlie Fish on 12/18/23. +// + +import Foundation + +func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? { + let tags: [[String]] = [["relay", relay.descriptor.url.id],["challenge", challenge_string]] + let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags) + return event +} diff --git a/damus/Nostr/NostrRequest.swift b/damus/Nostr/NostrRequest.swift @@ -38,7 +38,8 @@ enum NostrRequest { case subscribe(NostrSubscribe) case unsubscribe(String) case event(NostrEvent) - + case auth(NostrEvent) + var is_write: Bool { switch self { case .subscribe: @@ -47,6 +48,8 @@ enum NostrRequest { return false case .event: return true + case .auth: + return false } } diff --git a/damus/Nostr/NostrResponse.swift b/damus/Nostr/NostrResponse.swift @@ -23,7 +23,11 @@ enum NostrResponse { case notice(String) case eose(String) case ok(CommandResult) - + /// An [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) `auth` challenge. + /// + /// The associated type of this case is the challenge string sent by the server. + case auth(String) + var subid: String? { switch self { case .ok: @@ -34,6 +38,8 @@ enum NostrResponse { return sub_id case .notice: return nil + case .auth(let challenge_string): + return challenge_string } } @@ -94,6 +100,13 @@ enum NostrResponse { case NDB_TCE_NOTICE: free(data) return .notice("") + case NDB_TCE_AUTH: + defer { free(data) } + + guard let challenge_string = sized_cstr(cstr: tce.subid, len: tce.subid_len) else { + return nil + } + return .auth(challenge_string) default: free(data) return nil diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift @@ -57,6 +57,25 @@ enum RelayFlags: Int { case broken = 1 } +enum RelayAuthenticationError { + /// Only a public key was provided in keypair to sign challenge. + /// + /// A private key is required to sign `auth` challenge. + case no_private_key + /// No keypair was provided to sign challenge. + case no_key +} +enum RelayAuthenticationState: Equatable { + /// No `auth` request has been made from this relay + case none + /// We have received an `auth` challenge, but have not yet replied to the challenge + case pending + /// We have received an `auth` challenge and replied with an `auth` event + case verified + /// We received an `auth` challenge but failed to reply to the challenge + case error(RelayAuthenticationError) +} + struct Limitations: Codable { let payment_required: Bool? @@ -85,13 +104,15 @@ struct RelayMetadata: Codable { class Relay: Identifiable { let descriptor: RelayDescriptor let connection: RelayConnection - + var authentication_state: RelayAuthenticationState + var flags: Int init(descriptor: RelayDescriptor, connection: RelayConnection) { self.flags = 0 self.descriptor = descriptor self.connection = connection + self.authentication_state = RelayAuthenticationState.none } var is_broken: Bool { diff --git a/damus/Nostr/RelayConnection.swift b/damus/Nostr/RelayConnection.swift @@ -97,7 +97,7 @@ final class RelayConnection: ObservableObject { socket.send(.string(req)) } - func send(_ req: NostrRequestType) { + func send(_ req: NostrRequestType, callback: ((String) -> Void)? = nil) { switch req { case .typical(let req): guard let req = make_nostr_req(req) else { @@ -105,9 +105,11 @@ final class RelayConnection: ObservableObject { return } send_raw(req) + callback?(req) case .custom(let req): send_raw(req) + callback?(req) } } @@ -201,9 +203,20 @@ func make_nostr_req(_ req: NostrRequest) -> String? { return make_nostr_unsubscribe_req(sub_id) case .event(let ev): return make_nostr_push_event(ev: ev) + case .auth(let ev): + return make_nostr_auth_event(ev: ev) } } +func make_nostr_auth_event(ev: NostrEvent) -> String? { + guard let event = encode_json(ev) else { + return nil + } + let encoded = "[\"AUTH\",\(event)]" + print(encoded) + return encoded +} + func make_nostr_push_event(ev: NostrEvent) -> String? { guard let event = encode_json(ev) else { return nil diff --git a/damus/Nostr/RelayPool.swift b/damus/Nostr/RelayPool.swift @@ -31,13 +31,17 @@ class RelayPool { var seen: Set<SeenEvent> = Set() var counts: [String: UInt64] = [:] var ndb: Ndb + var keypair: Keypair? + var message_received_function: (((String, RelayDescriptor)) -> Void)? + var message_sent_function: (((String, Relay)) -> Void)? private let network_monitor = NWPathMonitor() private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor") private var last_network_status: NWPath.Status = .unsatisfied - init(ndb: Ndb) { + init(ndb: Ndb, keypair: Keypair? = nil) { self.ndb = ndb + self.keypair = keypair network_monitor.pathUpdateHandler = { [weak self] path in if (path.status == .satisfied || path.status == .requiresConnection) && self?.last_network_status != path.status { @@ -121,6 +125,7 @@ class RelayPool { else { return } let _ = self.ndb.process_event(str) + self.message_received_function?((str, desc)) }) let relay = Relay(descriptor: desc, connection: conn) self.relays.append(relay) @@ -244,7 +249,9 @@ class RelayPool { continue } - relay.connection.send(req) + relay.connection.send(req, callback: { str in + self.message_sent_function?((str, relay)) + }) } } @@ -298,7 +305,34 @@ class RelayPool { run_queue(relay_id) } } - + + // Handle auth + if case let .nostr_event(nostrResponse) = event, + case let .auth(challenge_string) = nostrResponse { + if let relay = get_relay(relay_id) { + print("received auth request from \(relay.descriptor.url.id)") + relay.authentication_state = .pending + if let keypair { + if let fullKeypair = keypair.to_full() { + if let authRequest = make_auth_request(keypair: fullKeypair, challenge_string: challenge_string, relay: relay) { + send(.auth(authRequest), to: [relay_id], skip_ephemeral: false) + relay.authentication_state = .verified + } else { + print("failed to make auth request") + } + } else { + print("keypair provided did not contain private key, can not sign auth request") + relay.authentication_state = .error(.no_private_key) + } + } else { + print("no keypair to reply to auth request") + relay.authentication_state = .error(.no_key) + } + } else { + print("no relay found for \(relay_id)") + } + } + for handler in handlers { handler.callback(relay_id, event) } diff --git a/damus/Notify/ReconnectRelaysNotify.swift b/damus/Notify/ReconnectRelaysNotify.swift @@ -0,0 +1,26 @@ +// +// ReconnectRelaysNotify.swift +// damus +// +// Created by Charlie Fish on 12/18/23. +// + +import Foundation + +struct ReconnectRelaysNotify: Notify { + typealias Payload = () + var payload: () +} + +extension NotifyHandler { + static var disconnect_relays: NotifyHandler<ReconnectRelaysNotify> { + .init() + } +} + +extension Notifications { + /// Reconnects all relays. + static var disconnect_relays: Notifications<ReconnectRelaysNotify> { + .init(.init(payload: ())) + } +} diff --git a/damus/Views/Onboarding/SuggestedUsersViewModel.swift b/damus/Views/Onboarding/SuggestedUsersViewModel.swift @@ -97,6 +97,9 @@ class SuggestedUsersViewModel: ObservableObject { case .ok: break + + case .auth: + break } } } diff --git a/damus/Views/Relays/Detail/RelayAuthenticationDetail.swift b/damus/Views/Relays/Detail/RelayAuthenticationDetail.swift @@ -0,0 +1,35 @@ +// +// RelayAuthenticationDetail.swift +// damus +// +// Created by Charlie Fish on 12/18/23. +// + +import SwiftUI + +struct RelayAuthenticationDetail: View { + let state: RelayAuthenticationState + + var body: some View { + switch state { + case .none: + EmptyView() + case .pending: + Text(NSLocalizedString("Pending", comment: "Label to display that authentication to a server is pending.")) + case .verified: + Text(NSLocalizedString("Authenticated", comment: "Label to display that authentication to a server has succeeded.")) + .foregroundStyle(DamusColors.success) + case .error: + Text(NSLocalizedString("Error", comment: "Label to display that authentication to a server has failed.")) + .foregroundStyle(DamusColors.danger) + } + } +} + +struct RelayAuthenticationDetail_Previews: PreviewProvider { + static var previews: some View { + RelayAuthenticationDetail(state: .none) + RelayAuthenticationDetail(state: .pending) + RelayAuthenticationDetail(state: .verified) + } +} diff --git a/damus/Views/Relays/RelayDetailView.swift b/damus/Views/Relays/RelayDetailView.swift @@ -92,7 +92,14 @@ struct RelayDetailView: View { } } } - + + if let authentication_state: RelayAuthenticationState = relay_object?.authentication_state, + authentication_state != .none { + Section(NSLocalizedString("Authentication", comment: "Header label to display authentication details for a given relay.")) { + RelayAuthenticationDetail(state: authentication_state) + } + } + if let pubkey = nip11?.pubkey { Section(NSLocalizedString("Admin", comment: "Label to display relay contact user.")) { UserViewRow(damus_state: state, pubkey: pubkey) @@ -175,9 +182,13 @@ struct RelayDetailView: View { } return attrString } - + + private var relay_object: Relay? { + state.pool.get_relay(relay) + } + private var relay_connection: RelayConnection? { - state.pool.get_relay(relay)?.connection + relay_object?.connection } } diff --git a/damus/Views/SaveKeysView.swift b/damus/Views/SaveKeysView.swift @@ -165,6 +165,8 @@ struct SaveKeysView: View { break case .ok: break + case .auth: + break } } } diff --git a/damus/damusApp.swift b/damus/damusApp.swift @@ -44,6 +44,9 @@ struct MainView: View { .onReceive(handle_notify(.logout)) { () in try? clear_keypair() keypair = nil + // We need to disconnect and reconnect to all relays when the user signs out + // This is to conform to NIP-42 and ensure we aren't persisting old connections + notify(.disconnect_relays) } .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in orientationTracker.setDeviceMajorAxis() diff --git a/damusTests/AuthIntegrationTests.swift b/damusTests/AuthIntegrationTests.swift @@ -0,0 +1,180 @@ +// +// AuthIntegrationTests.swift +// damusTests +// +// Created by Charlie Fish on 12/22/23. +// + +import XCTest +@testable import damus + +final class AuthIntegrationTests: XCTestCase { + func testAuthIntegrationFilterNostrWine() { + // Create relay pool and connect to `wss://filter.nostr.wine` + let relay_url = RelayURL("wss://filter.nostr.wine")! + var received_messages: [String] = [] + var sent_messages: [String] = [] + let keypair: Keypair = generate_new_keypair().to_keypair() + let pool = RelayPool(ndb: Ndb.test, keypair: keypair) + pool.message_received_function = { obj in + let str = obj.0 + let descriptor = obj.1 + + if descriptor.url.id != relay_url.id { + XCTFail("The descriptor we recieved the message from should equal the relayURL") + } + + received_messages.append(str) + } + pool.message_sent_function = { obj in + let str = obj.0 + let relay = obj.1 + + if relay.descriptor.url.id != relay_url.id { + XCTFail("The descriptor we sent the message to should equal the relayURL") + } + + sent_messages.append(str) + } + XCTAssertEqual(pool.relays.count, 0) + let relay_descriptor = RelayDescriptor.init(url: relay_url, info: .rw) + try! pool.add_relay(relay_descriptor) + XCTAssertEqual(pool.relays.count, 1) + let connection_expectation = XCTestExpectation(description: "Waiting for connection") + Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in + if pool.num_connected == 1 { + connection_expectation.fulfill() + timer.invalidate() + } + } + wait(for: [connection_expectation], timeout: 30.0) + XCTAssertEqual(pool.num_connected, 1) + // Assert that AUTH message has been received + XCTAssertTrue(received_messages.count >= 1, "expected recieved_messages to be >= 1") + let json_received = try! JSONSerialization.jsonObject(with: received_messages[0].data(using: .utf8)!, options: []) as! [Any] + XCTAssertEqual(json_received[0] as! String, "AUTH") + // Assert that we've replied with the AUTH response + XCTAssertEqual(sent_messages.count, 1) + let json_sent = try! JSONSerialization.jsonObject(with: sent_messages[0].data(using: .utf8)!, options: []) as! [Any] + XCTAssertEqual(json_sent[0] as! String, "AUTH") + let sent_msg = json_sent[1] as! [String: Any] + XCTAssertEqual(sent_msg["kind"] as! Int, 22242) + XCTAssertEqual((sent_msg["tags"] as! [[String]]).first { $0[0] == "challenge" }![1], json_received[1] as! String) + } + + func testAuthIntegrationRelayDamusIo() { + // Create relay pool and connect to `wss://relay.damus.io` + let relay_url = RelayURL("wss://relay.damus.io")! + var received_messages: [String] = [] + var sent_messages: [String] = [] + let keypair: Keypair = generate_new_keypair().to_keypair() + let pool = RelayPool(ndb: Ndb.test, keypair: keypair) + pool.message_received_function = { obj in + let str = obj.0 + let descriptor = obj.1 + + if descriptor.url.id != relay_url.id { + XCTFail("The descriptor we recieved the message from should equal the relayURL") + } + + received_messages.append(str) + } + pool.message_sent_function = { obj in + let str = obj.0 + let relay = obj.1 + + if relay.descriptor.url.id != relay_url.id { + XCTFail("The descriptor we sent the message to should equal the relayURL") + } + + sent_messages.append(str) + } + XCTAssertEqual(pool.relays.count, 0) + let relay_descriptor = RelayDescriptor.init(url: relay_url, info: .rw) + try! pool.add_relay(relay_descriptor) + XCTAssertEqual(pool.relays.count, 1) + let connection_expectation = XCTestExpectation(description: "Waiting for connection") + Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in + if pool.num_connected == 1 { + connection_expectation.fulfill() + timer.invalidate() + } + } + wait(for: [connection_expectation], timeout: 30.0) + XCTAssertEqual(pool.num_connected, 1) + // Assert that no AUTH messages have been received + XCTAssertEqual(received_messages.count, 0) + } + + func testAuthIntegrationNostrWine() { + // Create relay pool and connect to `wss://nostr.wine` + let relay_url = RelayURL("wss://nostr.wine")! + var received_messages: [String] = [] + var sent_messages: [String] = [] + let keypair: Keypair = generate_new_keypair().to_keypair() + let pool = RelayPool(ndb: Ndb.test, keypair: keypair) + pool.message_received_function = { obj in + let str = obj.0 + let descriptor = obj.1 + + if descriptor.url.id != relay_url.id { + XCTFail("The descriptor we recieved the message from should equal the relayURL") + } + + received_messages.append(str) + } + pool.message_sent_function = { obj in + let str = obj.0 + let relay = obj.1 + + if relay.descriptor.url.id != relay_url.id { + XCTFail("The descriptor we sent the message to should equal the relayURL") + } + + sent_messages.append(str) + } + XCTAssertEqual(pool.relays.count, 0) + let relay_descriptor = RelayDescriptor.init(url: relay_url, info: .rw) + try! pool.add_relay(relay_descriptor) + XCTAssertEqual(pool.relays.count, 1) + let connection_expectation = XCTestExpectation(description: "Waiting for connection") + Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in + if pool.num_connected == 1 { + connection_expectation.fulfill() + timer.invalidate() + } + } + wait(for: [connection_expectation], timeout: 30.0) + XCTAssertEqual(pool.num_connected, 1) + // Assert that no AUTH messages have been received + XCTAssertEqual(received_messages.count, 0) + // Generate UUID for subscription_id + let uuid = UUID().uuidString + // Send `["REQ", subscription_id, {"kinds": [4]}]` + let subscribe = NostrSubscribe(filters: [ + NostrFilter(kinds: [.dm]) + ], sub_id: uuid) + pool.send(NostrRequest.subscribe(subscribe)) + // Wait for AUTH message to have been received & sent + let msg_expectation = XCTestExpectation(description: "Waiting for messages") + Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in + if received_messages.count >= 2 && sent_messages.count >= 2 { + msg_expectation.fulfill() + timer.invalidate() + } + } + wait(for: [msg_expectation], timeout: 30.0) + // Assert that AUTH message has been received + XCTAssertTrue(received_messages.count >= 1, "expected recieved_messages to be >= 1") + let json_received = try! JSONSerialization.jsonObject(with: received_messages[0].data(using: .utf8)!, options: []) as! [Any] + XCTAssertEqual(json_received[0] as! String, "AUTH") + // Assert that we've replied with the AUTH response + XCTAssertEqual(sent_messages.count, 2) + let json_sent = try! JSONSerialization.jsonObject(with: sent_messages[1].data(using: .utf8)!, options: []) as! [Any] + XCTAssertEqual(json_sent[0] as! String, "AUTH") + let sent_msg = json_sent[1] as! [String: Any] + XCTAssertEqual(sent_msg["kind"] as! Int, 22242) + XCTAssertEqual((sent_msg["tags"] as! [[String]]).first { $0[0] == "challenge" }![1], json_received[1] as! String) + } + +} diff --git a/damusTests/Mocking/MockDamusState.swift b/damusTests/Mocking/MockDamusState.swift @@ -13,18 +13,7 @@ func generate_test_damus_state( mock_profile_info: [Pubkey: Profile]? ) -> DamusState { // Create a unique temporary directory - var tempDir: String! - do { - let fileManager = FileManager.default - let temp = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try fileManager.createDirectory(at: temp, withIntermediateDirectories: true, attributes: nil) - tempDir = temp.absoluteString - } catch { - tempDir = "." - } - - print("opening \(tempDir!)") - let ndb = try! Ndb(path: tempDir)! + let ndb = Ndb.test let our_pubkey = test_pubkey let pool = RelayPool(ndb: ndb) let settings = UserSettingsStore() diff --git a/damusTests/RequestTests.swift b/damusTests/RequestTests.swift @@ -16,7 +16,34 @@ final class RequestTests: XCTestCase { let expectedResult = "[\"CLOSE\",\"64FD064D-EB9E-4771-8255-8D16981B920B\"]" XCTAssertEqual(result, expectedResult) } - + + func testMakeAuthRequest() { + let challenge_string = "8bc847dd-f2f6-4b3a-9c8a-71776ad9b071" + let url = RelayURL("wss://example.com")! + let relayInfo = RelayInfo(read: true, write: true) + let relayDescriptor = RelayDescriptor(url: url, info: relayInfo) + let relayConnection = RelayConnection(url: url) { _ in + } processEvent: { _ in + } + + let relay = Relay(descriptor: relayDescriptor, connection: relayConnection) + let event = make_auth_request(keypair: FullKeypair.init(pubkey: Pubkey.empty, privkey: Privkey.empty), challenge_string: challenge_string, relay: relay)! + + let result = make_nostr_auth_event(ev: event) + let json = try! JSONSerialization.jsonObject(with: result!.data(using: .utf8)!, options: []) as! [Any] + + XCTAssertEqual(json[0] as! String, "AUTH") + let dictionary = json[1] as! [String: Any] + XCTAssertEqual(dictionary["content"] as! String, "") + XCTAssertEqual(dictionary["kind"] as! Int, 22242) + XCTAssertEqual(dictionary["sig"] as! String, String(repeating: "0", count: 128)) + XCTAssertEqual(dictionary["pubkey"] as! String, String(repeating: "0", count: 64)) + let tags = dictionary["tags"] as! [[String]] + XCTAssertEqual(tags.first { $0[0] == "relay" }![1], "wss://example.com") + XCTAssertEqual(tags.first { $0[0] == "challenge" }![1], challenge_string) + XCTAssertEqual(dictionary["id"] as! String, String(repeating: "0", count: 64)) + } + /* FIXME: these tests depend on order of json fields which is undefined func testMakePushEvent() { let now = Int64(Date().timeIntervalSince1970) diff --git a/damusTests/Util/NdbExtensions.swift b/damusTests/Util/NdbExtensions.swift @@ -0,0 +1,26 @@ +// +// NdbExtensions.swift +// damusTests +// +// Created by Charlie Fish on 12/23/23. +// + +import Foundation +@testable import damus + +extension Ndb { + static var test: Ndb { + var tempDir: String! + do { + let fileManager = FileManager.default + let temp = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fileManager.createDirectory(at: temp, withIntermediateDirectories: true, attributes: nil) + tempDir = temp.absoluteString + } catch { + tempDir = "." + } + + print("opening \(tempDir!)") + return Ndb(path: tempDir)! + } +} diff --git a/nostrdb/nostrdb.c b/nostrdb/nostrdb.c @@ -4017,6 +4017,17 @@ int ndb_ws_event_from_json(const char *json, int len, struct ndb_tce *tce, tce->command_result.msglen = toksize(tok); return 1; + } else if (tok_len == 4 && !memcmp("AUTH", json + tok->start, 4)) { + tce->evtype = NDB_TCE_AUTH; + + tok = &parser.toks[parser.i++]; + if (tok->type != JSMN_STRING) + return 0; + + tce->subid = json + tok->start; + tce->subid_len = toksize(tok); + + return 1; } return 0; diff --git a/nostrdb/nostrdb.h b/nostrdb/nostrdb.h @@ -103,6 +103,7 @@ enum tce_type { NDB_TCE_OK = 0x2, NDB_TCE_NOTICE = 0x3, NDB_TCE_EOSE = 0x4, + NDB_TCE_AUTH = 0x5, }; enum ndb_ingest_filter_action { diff --git a/nostrscript/NostrScript.swift b/nostrscript/NostrScript.swift @@ -193,7 +193,8 @@ enum NScriptEventType: Int { case note = 2 case notice = 3 case eose = 4 - + case auth = 5 + init(resp: NostrResponse) { switch resp { case .event: @@ -204,6 +205,8 @@ enum NScriptEventType: Int { self = .eose case .ok: self = .ok + case .auth: + self = .auth } } }