lnlink

iOS app for connecting to lightning nodes
git clone git://jb55.com/lnlink
Log | Files | Refs | Submodules | README | LICENSE

commit 9ac699cb599631a19d575916995cee0bb3a77907
parent 65029bace3c827adb56f3bb0cef2af0e7ee96b55
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 17 Aug 2022 13:48:23 -0700

lnurl-auth support

Changelog-Added: Added LNUrl-Auth support
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mlightninglink-c/lightninglink.h | 2++
Mlightninglink.xcodeproj/project.pbxproj | 16++++++++++++++++
Mlightninglink/LNSocket.swift | 10++++------
Mlightninglink/LNUrl.swift | 41+++++++++++++++++++++++++++++++++++++++++
Mlightninglink/RPC.swift | 14++++++++++++--
Alightninglink/Util/Hex.swift | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alightninglink/Views/AuthView.swift | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlightninglink/Views/ContentView.swift | 12+++++++++++-
Mlightninglink/Views/PayView.swift | 28++++++++++++++++++++--------
Mlightninglink/Views/ReceiveView.swift | 4+++-
10 files changed, 354 insertions(+), 18 deletions(-)

diff --git a/lightninglink-c/lightninglink.h b/lightninglink-c/lightninglink.h @@ -11,6 +11,8 @@ #include "lnsocket.h" #include "commando.h" #include "bech32.h" +#include "secp256k1.h" +#include "secp256k1_schnorrsig.h" void fd_do_zero(fd_set *); void fd_do_set(int, fd_set *); diff --git a/lightninglink.xcodeproj/project.pbxproj b/lightninglink.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ 4CCB0E2727C979F30026461C /* ScannerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCB0E2327C979F30026461C /* ScannerCoordinator.swift */; }; 4CCB0E2827C979F30026461C /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCB0E2427C979F30026461C /* ScannerViewController.swift */; }; 4CCB0E2B27CA71CA0026461C /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCB0E2A27CA71CA0026461C /* SetupView.swift */; }; + 4CD7641D28A205AB00B6928F /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD7641C28A205AB00B6928F /* AuthView.swift */; }; + 4CD7642028A28D7100B6928F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD7641F28A28D7100B6928F /* Hex.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,6 +89,8 @@ 4CCB0E2327C979F30026461C /* ScannerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerCoordinator.swift; sourceTree = "<group>"; }; 4CCB0E2427C979F30026461C /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = "<group>"; }; 4CCB0E2A27CA71CA0026461C /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; }; + 4CD7641C28A205AB00B6928F /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; }; + 4CD7641F28A28D7100B6928F /* Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hex.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -143,6 +147,7 @@ 4C641D172788FF2F002A36C9 /* lightninglink */ = { isa = PBXGroup; children = ( + 4CD7641E28A28D6700B6928F /* Util */, 4CCB0E2927CA71AA0026461C /* Views */, 4CCB0E1F27C979F30026461C /* CodeScanner */, 4C0359FE27AEEE8500FF92CE /* Info.plist */, @@ -225,10 +230,19 @@ 4C3DC97027D4829A00773021 /* SettingsView.swift */, 4C5EAC5527EE8544002A6FE5 /* ReceiveView.swift */, 4C5EAC5727EE8942002A6FE5 /* AmountInputView.swift */, + 4CD7641C28A205AB00B6928F /* AuthView.swift */, ); path = Views; sourceTree = "<group>"; }; + 4CD7641E28A28D6700B6928F /* Util */ = { + isa = PBXGroup; + children = ( + 4CD7641F28A28D7100B6928F /* Hex.swift */, + ); + path = Util; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -361,6 +375,7 @@ buildActionMask = 2147483647; files = ( 4C5EAC5827EE8942002A6FE5 /* AmountInputView.swift in Sources */, + 4CD7642028A28D7100B6928F /* Hex.swift in Sources */, 4C035A0027AEF90000FF92CE /* PayView.swift in Sources */, 4C873FD527A6EF3F008C972C /* LNSocket.swift in Sources */, 4C641D1B2788FF2F002A36C9 /* ContentView.swift in Sources */, @@ -368,6 +383,7 @@ 4CCB0E2727C979F30026461C /* ScannerCoordinator.swift in Sources */, 4C5EAC5427ED30A8002A6FE5 /* Rates.swift in Sources */, 4C641D492789083E002A36C9 /* lightninglink.c in Sources */, + 4CD7641D28A205AB00B6928F /* AuthView.swift in Sources */, 4CCB0E2627C979F30026461C /* CodeScanner.swift in Sources */, 4CCB0E2827C979F30026461C /* ScannerViewController.swift in Sources */, 4C72DB2527DD9D9B00834545 /* QR.swift in Sources */, diff --git a/lightninglink/LNSocket.swift b/lightninglink/LNSocket.swift @@ -36,9 +36,8 @@ public class LNSocket { } func write(_ data: Data) -> Bool { - data.withUnsafeBytes { msg in - return lnsocket_write(self.ln, msg, UInt16(data.count)) != 0 - } + var msg = Array(data) + return lnsocket_write(self.ln, &msg, UInt16(data.count)) != 0 } func fd() -> Int32 { @@ -66,9 +65,8 @@ public class LNSocket { } func pong(ping: Data) { - ping.withUnsafeBytes{ ping_ptr in - lnsocket_pong(self.ln, ping_ptr, UInt16(ping.count)) - } + var ping_ptr = Array(ping) + lnsocket_pong(self.ln, &ping_ptr, UInt16(ping.count)) } func perform_init() -> Bool { diff --git a/lightninglink/LNUrl.swift b/lightninglink/LNUrl.swift @@ -146,6 +146,47 @@ func handle_lnurl_request<T: Decodable>(_ url: URL, completion: @escaping (Eithe task.resume() } +public struct LNUrlAuth { + let k1: String + let tag: String + let url: URL + let host: String +} + +func is_lnurl_auth(_ url: URL) -> LNUrlAuth? { + var components = URLComponents() + components.query = url.query + + guard let items = components.queryItems else { + return nil + } + + guard let host = url.host else { + return nil + } + + var k1: String? = nil + var tag: String? = nil + + for item in items { + if item.name == "k1" { + k1 = item.value + } else if item.name == "tag" && item.value == "login" { + tag = item.value + } + } + + guard let k1 = k1 else { + return nil + } + + guard let tag = tag else { + return nil + } + + return LNUrlAuth(k1: k1, tag: tag, url: url, host: host) +} + func handle_lnurl(_ url: URL, completion: @escaping (LNUrl?) -> ()) { let task = URLSession.shared.dataTask(with: url) { (mdata, response, error) in guard let data = mdata else { diff --git a/lightninglink/RPC.swift b/lightninglink/RPC.swift @@ -93,7 +93,7 @@ public enum Decode { } } - func amount_msat() -> String? { + func amount_msat() -> Int64? { switch self { case .invoice(let inv): return inv.amount_msat @@ -114,7 +114,7 @@ public struct InvoiceDecode: Decodable { public var quantity_min: Int? public var description: String? public var node_id: String? - public var amount_msat: String? + public var amount_msat: Int64? public var vendor: String? } @@ -139,6 +139,10 @@ public struct ListFunds: Decodable { public static var empty = ListFunds(outputs: [], channels: []) } +public struct MakeSecret: Decodable { + public let secret: String +} + public struct Pay: Decodable { public var destination: String public var payment_hash: String @@ -428,6 +432,12 @@ public func rpc_listfunds(ln: LNSocket, token: String) -> RequestRes<ListFunds> return performRpc(ln: ln, operation: "listfunds", authToken: token, timeout_ms: default_timeout, params: params) } +public func rpc_makesecret(ln: LNSocket, token: String, hex: String) -> RequestRes<MakeSecret> +{ + let params: Array<String> = [hex] + return performRpc(ln: ln, operation: "makesecret", authToken: token, timeout_ms: 1000, params: params) +} + public func rpc_decode(ln: LNSocket, token: String, inv: String) -> RequestRes<InvoiceDecode> { let params = [ inv ]; diff --git a/lightninglink/Util/Hex.swift b/lightninglink/Util/Hex.swift @@ -0,0 +1,81 @@ +// +// Hex.swift +// lightninglink +// +// Created by William Casarin on 2022-08-09. +// + +import Foundation + +func hexchar(_ val: UInt8) -> UInt8 { + if val < 10 { + return 48 + val; + } + if val < 16 { + return 97 + val - 10; + } + assertionFailure("impossiburu") + return 0 +} + + +func hex_encode(_ data: [UInt8]) -> String { + var str = "" + for c in data { + let c1 = hexchar(c >> 4) + let c2 = hexchar(c & 0xF) + + str.append(Character(Unicode.Scalar(c1))) + str.append(Character(Unicode.Scalar(c2))) + } + return str +} + + +func char_to_hex(_ c: UInt8) -> UInt8? +{ + // 0 && 9 + if (c >= 48 && c <= 57) { + return c - 48 // 0 + } + // a && f + if (c >= 97 && c <= 102) { + return c - 97 + 10; + } + // A && F + if (c >= 65 && c <= 70) { + return c - 65 + 10; + } + return nil; +} + + +func hex_decode(_ str: String) -> [UInt8]? +{ + if str.count == 0 { + return nil + } + var ret: [UInt8] = [] + let chars = Array(str.utf8) + var i: Int = 0 + for c in zip(chars, chars[1...]) { + i += 1 + + if i % 2 == 0 { + continue + } + + guard let c1 = char_to_hex(c.0) else { + return nil + } + + guard let c2 = char_to_hex(c.1) else { + return nil + } + + ret.append((c1 << 4) | c2) + } + + return ret +} + diff --git a/lightninglink/Views/AuthView.swift b/lightninglink/Views/AuthView.swift @@ -0,0 +1,164 @@ +// +// AuthView.swift +// lightninglink +// +// Created by William Casarin on 2022-08-08. +// + +import SwiftUI +import CryptoKit +import CommonCrypto + + +struct AuthView: View { + let auth: LNUrlAuth + let lnlink: LNLink + @State var error: String? = nil + + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 20) { + Text(auth.url.host ?? "Unknown") + .font(.largeTitle) + + Button(action: { login() }) { + Text(auth.tag.capitalized) + .padding() + .font(.largeTitle) + } + .background { + Color.accentColor + } + .foregroundColor(Color.white) + .cornerRadius(20) + + if let error = error { + Text(error) + .foregroundColor(Color.red) + .padding(20) + } + } + } + + func login() { + let ln = LNSocket() + guard ln.connect_and_init(node_id: lnlink.node_id, host: lnlink.host) else { + error = "Could not connect to node" + return + } + guard let hex = make_secret_hex(self.auth) else { + error = "Could not make secret" + return + } + Task.init { + let res = rpc_makesecret(ln: ln, token: lnlink.token, hex: hex) + switch res { + case .failure(let err): + self.error = err.description + case .success(let makesec): + let sec = makesec.secret + await do_login(sec, auth: self.auth) + } + } + } + + func do_login(_ hexsec: String, auth: LNUrlAuth) async { + var url_str = auth.url.absoluteString + + var dersig = Array<UInt8>.init(repeating: 0, count: 72) + var pk = Array<UInt8>.init(repeating: 0, count: 33) + var sig = secp256k1_ecdsa_signature() + + guard let sec = hex_decode(hexsec) else { + self.error = "Could not hex decode secret key" + return + } + + guard var msg = hex_decode(auth.k1) else { + self.error = "Could not decode k1 challenge string as hex: '\(auth.k1)'" + return + } + + + let opts = UInt32(SECP256K1_CONTEXT_SIGN) + guard let ctx = secp256k1_context_create(opts) else { + return + } + + var pubkey = secp256k1_pubkey() + + //let msg2 = sha256(msg) + + guard secp256k1_ecdsa_sign(ctx, &sig, msg, sec, nil, nil) == 1 else { + self.error = "Failed to sign" + return + } + + var siglen: Int = 72 + guard secp256k1_ecdsa_signature_serialize_der(ctx, &dersig, &siglen, &sig) == 1 else { + self.error = "Failed to encode DER ecdsa signature" + return + } + + dersig = Array(dersig[..<siglen]) + + defer { secp256k1_context_destroy(ctx) } + + guard secp256k1_ec_pubkey_create(ctx, &pubkey, sec) == 1 else { + self.error = "Failed to get pubkey from keypair" + return + } + + var pklen: Int = 33 + guard secp256k1_ec_pubkey_serialize(ctx, &pk, &pklen, &pubkey, UInt32(SECP256K1_EC_COMPRESSED)) == 1 else { + self.error = "Failed to serialize pubkey" + return + } + + let hex_key = hex_encode(pk) + let hex_sig = hex_encode(dersig) + + url_str += "&sig=" + hex_sig + "&key=" + hex_key + + guard let url = URL(string: url_str) else { + self.error = "Invalid url: \(url_str)" + return + } + + // (data, resp) + guard let (data, resp) = try? await URLSession.shared.data(from: url) else { + self.error = "Login failed" + return + } + + print("\(resp)") + print("\(data)") + + dismiss() + } + +} + +func make_secret_hex(_ auth: LNUrlAuth) -> String? { + guard let host_data = auth.host.data(using: .utf8) else { + return nil + } + return hex_encode(Array(host_data)) +} + +struct AuthView_Previews: PreviewProvider { + + static var previews: some View { + let auth = LNUrlAuth(k1: "k1", tag: "login", url: URL(string: "jb55.com")!, host: "jb55.com") + let lnlink = LNLink(token: "", host: "", node_id: "") + AuthView(auth: auth, lnlink: lnlink) + } +} + +func sha256(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + var data = data + CC_SHA256(&data, CC_LONG(data.count), &hash) + return hash +} diff --git a/lightninglink/Views/ContentView.swift b/lightninglink/Views/ContentView.swift @@ -38,6 +38,8 @@ public enum ActiveSheet: Identifiable { switch self { case .receive: return "receive" + case .auth: + return "auth" case .qr: return "qrcode" case .pay: @@ -48,6 +50,7 @@ public enum ActiveSheet: Identifiable { case qr case receive case pay(DecodeType) + case auth(LNUrlAuth) } struct Funds { @@ -205,6 +208,9 @@ struct ContentView: View { } .sheet(item: $active_sheet) { sheet in switch sheet { + case .auth(let auth): + AuthView(auth: auth, lnlink: lnlink) + case .qr: CodeScannerView(codeTypes: SCAN_TYPES) { res in switch res { @@ -266,7 +272,11 @@ struct ContentView: View { // TODO: report that this is an lnlink, not an invoice case .lnurl(let lnurl): let decode: DecodeType = .lnurl(lnurl) - self.active_sheet = .pay(decode) + if let auth = is_lnurl_auth(lnurl) { + self.active_sheet = .auth(auth) + } else { + self.active_sheet = .pay(decode) + } } } diff --git a/lightninglink/Views/PayView.swift b/lightninglink/Views/PayView.swift @@ -77,6 +77,7 @@ public enum PayState { case decoded(DecodeType) case ready(Invoice) case invoice_request(RequestInvoice) + case auth(LNUrlAuth) } let default_placeholder = "10,000" @@ -253,10 +254,7 @@ struct PayView: View { guard let invoice = self.invoice else { return } - guard let amount_msat_str = invoice.amount_msat() else { - return - } - guard let amount_msat = parse_msat(amount_msat_str) else { + guard let amount_msat = invoice.amount_msat() else { return } @@ -468,12 +466,18 @@ struct PayView: View { } } } - + + func handle_confirm_auth(ln mln: LNSocket?, auth: LNUrlAuth) { + } + func handle_confirm(ln mln: LNSocket?) { // clear last error on confirm self.error = nil switch self.state { + case .auth(let auth): + return handle_confirm_auth(ln: mln, auth: auth) + case .invoice_request(let reqinv): switch reqinv { case .offer(let offer): @@ -507,6 +511,8 @@ struct PayView: View { func handle_state_change() { switch self.state { + case .auth: + break case .ready: break case .invoice_request: @@ -539,7 +545,11 @@ struct PayView: View { switch_state(.invoice_request(.lnurl(lnurlp))) } - + + func handle_lnurl_auth(ln: LNSocket, auth: LNUrlAuth) { + switch_state(.auth(auth)) + } + func handle_decode(_ oldln: LNSocket?, decode: DecodeType) { let ln = oldln ?? LNSocket() if oldln == nil { @@ -577,7 +587,7 @@ struct PayView: View { } else if decoded.type == "bolt11 invoice" || decoded.type == "bolt12 invoice" { var amount: InvoiceAmount = .any if decoded.amount_msat != nil { - guard let amt = parse_msat(decoded.amount_msat!) else { + guard let amt = decoded.amount_msat else { self.error = "invalid msat amount: \(decoded.amount_msat!)" return } @@ -723,6 +733,7 @@ func is_ready(_ state: PayState) -> ReadyInvoice? { return .direct(invoice) case .invoice_request(let invreq): return .requested(invreq) + case .auth: fallthrough case .initial: fallthrough case .decoding: fallthrough case .decoded: @@ -774,7 +785,7 @@ struct PayView_Previews: PreviewProvider { func handle_bolt12_offer(ln: LNSocket, decoded: InvoiceDecode, inv: String) -> Either<String, PayState> { if decoded.amount_msat != nil { - guard let min_amt = parse_msat(decoded.amount_msat!) else { + guard let min_amt = decoded.amount_msat else { return .left("Error parsing amount_msat: '\(decoded.amount_msat!)'") } let offer = Offer(offer: inv, amount: .min(min_amt), decoded: decoded) @@ -789,6 +800,7 @@ func handle_bolt12_offer(ln: LNSocket, decoded: InvoiceDecode, inv: String) -> E func should_show_confirm(_ state: PayState) -> Bool { switch state { case .ready: fallthrough + case .auth: fallthrough case .invoice_request: return true diff --git a/lightninglink/Views/ReceiveView.swift b/lightninglink/Views/ReceiveView.swift @@ -110,7 +110,9 @@ struct ReceiveView: View { } Spacer() - + + + if !self.making && self.qr_data == nil { Button("Receive") { self.making = true