Skip to content

Commit

Permalink
Early cut of Nostr HTTP Auth for Images
Browse files Browse the repository at this point in the history
  • Loading branch information
blakejakopovic committed May 17, 2023
1 parent 9093bde commit e27162b
Show file tree
Hide file tree
Showing 7 changed files with 312 additions and 36 deletions.
4 changes: 4 additions & 0 deletions damus.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; };
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; };
BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; };
BE7A04A12A12870200A71A77 /* NostrHTTPAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7A04A02A12870200A71A77 /* NostrHTTPAuthManager.swift */; };
DD597CBD2963D85A00C64D32 /* MarkdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD597CBC2963D85A00C64D32 /* MarkdownTests.swift */; };
E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; };
E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E4ED0A295867B900DD7078 /* ThreadView.swift */; };
Expand Down Expand Up @@ -701,6 +702,7 @@
9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachMediaUtility.swift; sourceTree = "<group>"; };
BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
BE7A04A02A12870200A71A77 /* NostrHTTPAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrHTTPAuthManager.swift; sourceTree = "<group>"; };
DD597CBC2963D85A00C64D32 /* MarkdownTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarkdownTests.swift; sourceTree = "<group>"; };
E990020E2955F837003BBC5A /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = "<group>"; };
E9E4ED0A295867B900DD7078 /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -862,6 +864,7 @@
4C54AA0629A540BA003E4487 /* NotificationsModel.swift */,
4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */,
3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */,
BE7A04A02A12870200A71A77 /* NostrHTTPAuthManager.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -1749,6 +1752,7 @@
4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */,
4C363A962827096D006E126D /* PostBlock.swift in Sources */,
4C5F9116283D855D0052CD1C /* EventsModel.swift in Sources */,
BE7A04A12A12870200A71A77 /* NostrHTTPAuthManager.swift in Sources */,
4CEE2AED2805B22500AB5EEF /* NostrRequest.swift in Sources */,
4C06670E28FDEAA000038D2A /* utf8.c in Sources */,
4C3EA66D28FF782800C48A62 /* amount.c in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion damus/Components/ImageCarousel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ struct ImageCarousel: View {
}
}
.fullScreenCover(isPresented: $open_sheet) {
ImageView(urls: urls, disable_animation: state.settings.disable_animation)
ImageView(urls: urls, state: state, disable_animation: state.settings.disable_animation)
}
.frame(height: self.height)
.onTapGesture {
Expand Down
3 changes: 2 additions & 1 deletion damus/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,8 @@ struct ContentView: View {
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
muted_threads: MutedThreadsManager(keypair: keypair)
muted_threads: MutedThreadsManager(keypair: keypair),
http_auth_manager: NostrHTTPAuthManager(keypair: keypair, domainExpiries: [])
)
home.damus_state = self.damus_state!

Expand Down
3 changes: 2 additions & 1 deletion damus/Models/DamusState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct DamusState {
let bootstrap_relays: [String]
let replies: ReplyCounter
let muted_threads: MutedThreadsManager
let http_auth_manager: NostrHTTPAuthManager

@discardableResult
func add_zap(zap: Zap) -> Bool {
Expand All @@ -47,5 +48,5 @@ struct DamusState {
}

static var empty: DamusState {
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(keypair: Keypair(pubkey: "", privkey: nil))) }
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(keypair: Keypair(pubkey: "", privkey: nil)), http_auth_manager: NostrHTTPAuthManager(keypair: Keypair(pubkey: "", privkey: ""), domainExpiries: [])) }
}
178 changes: 178 additions & 0 deletions damus/Models/NostrHTTPAuthManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//
// NostrHTTPAuthManager.swift
// damus
//
// Created by Blake Jakopovic on 15/5/2023.
//

import Foundation
import Kingfisher
import UIKit

class NostrHTTPAuthManager {
struct DomainData {
let domain: String
let expiration: Date?
}

private let keypair: Keypair
private var domains: [DomainData]

init(keypair: Keypair, domainExpiries: [(domain: String, expiry: Date?)]) {
self.keypair = keypair

// TODO: Ideally we load the domains from a persistant store without param
self.domains = domainExpiries.map { domainExpiry in
return DomainData(domain: domainExpiry.domain, expiration: domainExpiry.expiry)
}

// TODO: Will need a mechanism to cleanup/remove all stale trusted domains
// domains.removeAll { $0.expiration < Date() }
}

func isDomainTrusted(_ domain: String) -> Bool {
return domains.contains { $0.domain == domain && isValidDomain($0) }
}

func addDomain(_ domain: String, expiration: Date?) {
// TODO: Persist this change
domains.append(DomainData(domain: domain, expiration: expiration))
if expiration == nil {
print("Adding domain for nostr http auth: \(domain) - no expiry")
} else {
print("Adding domain for nostr http auth: \(domain) - with expiry")
}
print(self.domains)
}

// TODO: We likely want a list view in settings that displays these domains
func removeDomain(_ domain: String) {
// TODO: Persist this change
domains.removeAll { $0.domain == domain }
print("Removing domain for nostr http auth: \(domain)")
}

private func isValidDomain(_ domainData: DomainData) -> Bool {
// If a domain expiry is set, make sure it's in the future
if let expiration = domainData.expiration {
return expiration > Date()
}
return true
}

func testNostHttpAuthHeader(headers: NSDictionary) -> Bool {
if let dict = headers as? [String: String],
dict.contains(where: {
$0.key.uppercased() == "WWW-AUTHENTICATE" &&
$0.value.uppercased() == "NOSTR-NIP-98" }
) {
return true
} else {
return false
}
}

func getHttpAuthHeaderValue(url: URL) -> String? {

var tags: [[String]] = [["method", "GET"]]

// For now the spec only specifies a single u tag.. but I expect that to change
tags.append(["u", url.absoluteString])

let auth_event = NostrEvent(content: "", pubkey: keypair.pubkey, kind: 27235, tags: tags)
auth_event.calculate_id()
// TODO: This can error when state.keypair.privkey is nil
if keypair.privkey == nil {
return nil
}
auth_event.sign(privkey: keypair.privkey!)

let signed_auth_event_base64: String = Data(encode_json(auth_event)!.utf8).base64EncodedString()

// Authorization: Nostr BASE64_HTTP_AUTH_EVENT
let auth_header_value = "Nostr \(signed_auth_event_base64)"

return auth_header_value
}

func getRequestModifier(url: URL) -> AnyModifier {
// TODO: Fix error handling when private key is not set, so cannot sign event
// This is likely mostly a xcode preview issue, rather than normal ops
let auth_header_value = getHttpAuthHeaderValue(url: url) ?? ""

return AnyModifier { request in
var req = request
req.addValue(auth_header_value, forHTTPHeaderField: "Authorization")
return req
}
}

enum Result {
case success(KFCrossPlatformImage)
case domainTrustRequired(String)
case hardFailure(Error)
}

func loadImage(url: URL, withHttpAuthHeader: Bool, completion: ((_ image: Result) -> Void?)?) {

print("loadImage called: \(url), \(withHttpAuthHeader)")
let _retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(3))
var options: KingfisherOptionsInfo = []; // .retryStrategy(retry)

// Check if domain is trusted, or auth has been requested
let is_http_auth_domain = isDomainTrusted(url.host!)

if withHttpAuthHeader && !isDomainTrusted(url.host!) {
completion!(.domainTrustRequired(url.host!))
return
}

let use_nostr_http_auth = withHttpAuthHeader || is_http_auth_domain

if use_nostr_http_auth {
let authModifier = getRequestModifier(url: url);
options += [.requestModifier(authModifier)]
}

KingfisherManager.shared.retrieveImage(with: url, options: options) { result in
switch result {
case .success(let value):
print("Successful Image Nostr HTTP AUTH \(url)")
completion!(.success(value.image))
return
case .failure(let error):
print("Error: Failed to fetch image with auth \(String(describing: error.failureReason))")

// Check if we already tried using Nostr HTTP Auth - if we did, likely no point trying again
if !use_nostr_http_auth {
print("!use_nostr_http_auth")
if !error.isInvalidResponseStatusCode(401) && !error.isInvalidResponseStatusCode(402) {

print("Error, but wasn't !401 && !402 - so we can't handle it")
// It wasn't 401 Unauthorized or 402 Payment Required
completion!(.hardFailure(error))
return
}

// Check the headers to see if Nostr HTTP AUTH is supported
if case let .responseError(.invalidHTTPStatusCode(response)) = error {

if self.testNostHttpAuthHeader(headers: response.allHeaderFields as NSDictionary) {

// We have found a header match, so we can retry with auth
print("NostHttpAuthHeader match - retrying request with auth header")

// If we match on the auth header, but don't yet trust this domain, use completion handler to let the UI show this
if !self.isDomainTrusted(url.host!) {
completion!(.domainTrustRequired(url.host!))
return
}

self.loadImage(url: url, withHttpAuthHeader: true, completion: completion)
}
}
}
}
}
}
}
Loading

0 comments on commit e27162b

Please sign in to comment.