From 3c610a83cd98c8cf4ccb529722df3ef9bf115b63 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:14:08 +0200 Subject: [PATCH] Cashu ecash chips: detect, render, and redeem tokens + /pay command (#1376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cashu ecash chips: detect, render, and redeem tokens + /pay command Content-level Cashu support, no wire-protocol changes: - CashuTokenDecoder: summarizes V3 (cashuA base64url-JSON) tokens — amount summed across proofs, unit, mint host, memo — and V4 (cashuB) via a minimal bounded CBOR reader. All input is treated as adversarial: size caps, depth/item budgets, overflow guards, display sanitization; malformed payloads fail closed to a generic chip. - PaymentChipView: cashu chips now show "500 sat · mint.example.com" (+ memo) instead of a generic label; tap opens a cashu: wallet URL and falls back to https://redeem.cashu.me when no wallet handles it; context menu adds copy token / redeem in wallet / redeem on web. - extractCashuLinks now returns bare deduplicated bearer strings so the chip can decode them (cashu: URIs still detected via the embedded token). - /pay : validates the token decodes, sends it as the message body; DMs send directly, public channels require an explicit "/pay public" confirm since tokens are bearer instruments. Suggested everywhere except public geohash channels. - Tests: decoder (V3/V4 decode, summation, URI forms, truncation/ garbage/huge fuzzing, CBOR depth bounds) and /pay command flows. Co-Authored-By: Claude Fable 5 * Cashu: strict decode on the /pay SEND path The permissive decoder turned any non-empty cashuB… base64 that failed CBOR parsing into a generic TokenInfo, so the /pay guard accepted base64 junk and truncated V4 tokens and relayed them with a success message. Add a `strict` flag to CashuTokenDecoder.decode: in strict mode there is no permissive V4 fallback and the token must resolve to a known version with a positive amount, else it returns nil. Rendering keeps the permissive path (an unknown chip is fine for display). /pay now decodes with strict:true and surfaces "invalid cashu token" instead of sending. Tests: /pay with truncated cashuB / base64 junk is rejected; valid V3 and valid definite-length V4 still send; decoder strict-mode unit tests. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 60 +++ bitchat/Models/CommandInfo.swift | 17 +- bitchat/Services/CashuTokenDecoder.swift | 339 +++++++++++++++++ bitchat/Services/CommandProcessor.swift | 41 ++ bitchat/ViewModels/ChatViewModel.swift | 8 + .../Views/Components/PaymentChipView.swift | 170 ++++++++- bitchat/Views/MessageTextHelpers.swift | 15 +- bitchatTests/CashuTokenDecoderTests.swift | 350 ++++++++++++++++++ bitchatTests/CommandProcessorTests.swift | 172 +++++++++ 9 files changed, 1149 insertions(+), 23 deletions(-) create mode 100644 bitchat/Services/CashuTokenDecoder.swift create mode 100644 bitchatTests/CashuTokenDecoderTests.swift diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7301e613..66fd04b4 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -15425,6 +15425,18 @@ } } }, + "content.commands.pay" : { + "comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send a cashu ecash token in this chat" + } + } + } + }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { @@ -18469,6 +18481,18 @@ } } }, + "content.input.token_placeholder" : { + "comment" : "Placeholder shown after /pay in the command suggestion panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + } + } + }, "content.jump.new_count" : { "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", "extractionState" : "manual", @@ -19734,6 +19758,18 @@ } } }, + "content.payment.copy_token" : { + "comment" : "Context menu action copying a Cashu token to the pasteboard", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "copy token" + } + } + } + }, "content.payment.lightning" : { "extractionState" : "manual", "localizations" : { @@ -19913,6 +19949,30 @@ } } }, + "content.payment.redeem_wallet" : { + "comment" : "Context menu action opening a Cashu token in an ecash wallet app", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem in wallet" + } + } + } + }, + "content.payment.redeem_web" : { + "comment" : "Context menu action opening a Cashu token in the web redemption page", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem on web" + } + } + } + }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 1f8245be..0ad3d554 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -20,6 +20,7 @@ enum CommandInfo: String, Identifiable { case hug case message = "msg" case slap + case pay case unblock case who case favorite = "fav" @@ -33,6 +34,8 @@ enum CommandInfo: String, Identifiable { switch self { case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: return "<" + String(localized: "content.input.nickname_placeholder") + ">" + case .pay: + return "<" + String(localized: "content.input.token_placeholder") + ">" case .clear, .help, .who: return nil } @@ -45,6 +48,7 @@ enum CommandInfo: String, Identifiable { case .help: String(localized: "content.commands.help") case .hug: String(localized: "content.commands.hug") case .message: String(localized: "content.commands.message") + case .pay: String(localized: "content.commands.pay") case .slap: String(localized: "content.commands.slap") case .unblock: String(localized: "content.commands.unblock") case .who: String(localized: "content.commands.who") @@ -54,12 +58,19 @@ enum CommandInfo: String, Identifiable { } static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { - let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] + var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] + // Cashu tokens are bearer instruments: in a public geohash any nearby + // stranger can redeem one, so don't *suggest* /pay there (the + // processor still allows it behind an explicit "public" confirm). + // Payments make sense in every DM and in mesh public. + if !isGeoPublic { + commands.append(.pay) + } // The processor rejects favorites in geohash contexts, so only // suggest them where they actually work: mesh. if isGeoPublic || isGeoDM { - return baseCommands + return commands } - return baseCommands + [.favorite, .unfavorite] + return commands + [.favorite, .unfavorite] } } diff --git a/bitchat/Services/CashuTokenDecoder.swift b/bitchat/Services/CashuTokenDecoder.swift new file mode 100644 index 00000000..87dfee02 --- /dev/null +++ b/bitchat/Services/CashuTokenDecoder.swift @@ -0,0 +1,339 @@ +// +// CashuTokenDecoder.swift +// bitchat +// +// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` = +// base64url CBOR) just far enough to summarize them for the UI: total +// amount, unit, mint host, and memo. The app never contacts a mint — tokens +// are bearer strings and redemption is delegated to an external wallet. +// +// This parses attacker-controlled message content, so every path is +// bounds-checked, size-capped, and returns nil instead of trapping. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +enum CashuTokenDecoder { + + struct TokenInfo: Equatable { + /// Token serialization version: "A" (JSON) or "B" (CBOR). + let version: String + /// Sum of all proof amounts; nil when no valid amounts were found. + let amount: Int? + /// Currency unit as declared by the token (commonly "sat"), if any. + let unit: String? + /// Host of the (first) mint URL, for display. + let mintHost: String? + /// Optional sender memo, sanitized for display. + let memo: String? + + /// "500 sat" style summary, defaulting the unit to sats per NUT-00. + var displayAmount: String? { + amount.map { "\($0) \(unit ?? "sat")" } + } + } + + /// Upper bound on accepted token length in characters. Real tokens are a + /// few KB; anything much bigger is abuse we shouldn't spend CPU on. + static let maxTokenLength = 60_000 + /// Per-proof and total amount sanity caps (order of total sats in existence). + private static let maxAmount: Int64 = 2_100_000_000_000_000 + + // MARK: - Public API + + /// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be + /// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the + /// input doesn't look like a Cashu token at all. + static func bareToken(from raw: String) -> String? { + var token = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = token.lowercased() + if lower.hasPrefix("cashu://") { + token = String(token.dropFirst(8)) + } else if lower.hasPrefix("cashu:") { + token = String(token.dropFirst(6)) + } + if token.contains("%"), let decoded = token.removingPercentEncoding { + token = decoded + } + guard token.count >= 12, token.count <= maxTokenLength else { return nil } + guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil } + // Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens) + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=.")) + guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + return token + } + + /// Decodes a token (raw or `cashu:` URI form) into a display summary. + /// + /// In the default (permissive) mode this is for *rendering*: V3 tokens + /// must parse as JSON, but a V4 token whose CBOR we cannot walk still + /// returns a generic `TokenInfo` (version "B", no amount) because the + /// payload may use encodings this minimal reader doesn't support — an + /// unknown chip is fine for display. + /// + /// In `strict` mode (used by the `/pay` SEND path) there is no permissive + /// fallback: the token must cleanly decode to a known version *and* carry + /// a positive amount, otherwise this returns nil. This stops base64 junk + /// and truncated V4 tokens from being relayed as if they were valid money. + static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? { + guard let token = bareToken(from: raw) else { return nil } + let version = String(token[token.index(token.startIndex, offsetBy: 5)]) + guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else { + return nil + } + let info: TokenInfo? + switch version { + case "A": + info = decodeV3(payload) + case "B": + if let walked = decodeV4(payload) { + info = walked + } else if strict { + // Couldn't cleanly walk the CBOR — refuse to send it. + return nil + } else { + info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil) + } + default: + return nil + } + guard let info else { return nil } + if strict { + // A sendable token must resolve to a positive, sane amount. + guard let amount = info.amount, amount > 0 else { return nil } + } + return info + } + + // MARK: - Base64url + + private static func base64URLDecode(_ input: String) -> Data? { + var s = input + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Normalize padding (wallets emit both padded and unpadded forms) + s = s.replacingOccurrences(of: "=", with: "") + let remainder = s.count % 4 + if remainder == 1 { return nil } + if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) } + return Data(base64Encoded: s) + } + + // MARK: - V3 (JSON) + + private static func decodeV3(_ payload: Data) -> TokenInfo? { + guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any], + let entries = obj["token"] as? [[String: Any]], + !entries.isEmpty else { + return nil + } + var total: Int64 = 0 + var sawAmount = false + var mintHost: String? + for entry in entries { + if mintHost == nil, let mint = entry["mint"] as? String { + mintHost = sanitizedHost(from: mint) + } + for proof in (entry["proofs"] as? [[String: Any]]) ?? [] { + guard let number = proof["amount"] as? NSNumber else { continue } + let value = number.int64Value + guard value > 0, value <= maxAmount else { continue } + total += value + guard total <= maxAmount else { return nil } + sawAmount = true + } + } + return TokenInfo( + version: "A", + amount: sawAmount ? Int(total) : nil, + unit: sanitizedUnit(obj["unit"] as? String), + mintHost: mintHost, + memo: sanitizedMemo(obj["memo"] as? String) + ) + } + + // MARK: - V4 (CBOR) + + /// Minimal walk of the NUT-00 TokenV4 CBOR map: + /// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] } + private static func decodeV4(_ payload: Data) -> TokenInfo? { + var reader = CBORReader(data: payload) + guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil } + var mintHost: String? + var unit: String? + var memo: String? + var total: Int64 = 0 + var sawAmount = false + for (key, value) in pairs { + guard case .text(let name) = key else { continue } + switch (name, value) { + case ("m", .text(let mint)): + mintHost = sanitizedHost(from: mint) + case ("u", .text(let u)): + unit = sanitizedUnit(u) + case ("d", .text(let d)): + memo = sanitizedMemo(d) + case ("t", .array(let groups)): + for case .map(let group) in groups { + for case (.text("p"), .array(let proofs)) in group { + for case .map(let proof) in proofs { + for case (.text("a"), .unsigned(let amount)) in proof { + guard amount > 0, amount <= UInt64(maxAmount) else { continue } + total += Int64(amount) + guard total <= maxAmount else { return nil } + sawAmount = true + } + } + } + } + default: + break + } + } + return TokenInfo( + version: "B", + amount: sawAmount ? Int(total) : nil, + unit: unit, + mintHost: mintHost, + memo: memo + ) + } + + // MARK: - Display Sanitization (values are attacker-controlled) + + private static func sanitizedHost(from mint: String) -> String? { + guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil } + return String(host.lowercased().prefix(48)) + } + + private static func sanitizedUnit(_ unit: String?) -> String? { + guard let unit, !unit.isEmpty, unit.count <= 12, + unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else { + return nil + } + return unit + } + + private static func sanitizedMemo(_ memo: String?) -> String? { + guard let memo, memo.count <= 512 else { return nil } + let stripped = CharacterSet.controlCharacters.union(.newlines) + var cleaned = "" + cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) }) + cleaned = cleaned.trimmingCharacters(in: .whitespaces) + guard !cleaned.isEmpty else { return nil } + return String(cleaned.prefix(80)) + } +} + +// MARK: - Minimal CBOR Reader + +/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in +/// depth, item count, and byte length; indefinite-length items and anything +/// else exotic make the parse fail (the caller degrades to a generic chip). +private struct CBORReader { + indirect enum Value { + case unsigned(UInt64) + case text(String) + case array([Value]) + case map([(Value, Value)]) + /// Parsed-and-skipped content we don't need (byte strings, negatives, floats…) + case opaque + } + + private let bytes: [UInt8] + private var index = 0 + /// Total item budget so hostile nesting can't run away. + private var itemBudget = 50_000 + private static let maxDepth = 16 + private static let maxContainerCount: UInt64 = 10_000 + + init(data: Data) { + bytes = [UInt8](data) + } + + mutating func parseValue(depth: Int) -> Value? { + guard depth < Self.maxDepth, itemBudget > 0 else { return nil } + itemBudget -= 1 + guard let (major, argument) = readHead() else { return nil } + switch major { + case 0: // unsigned int + return .unsigned(argument) + case 1: // negative int (argument already consumed) + return .opaque + case 2: // byte string + return readBytes(count: argument) != nil ? .opaque : nil + case 3: // text string + guard let raw = readBytes(count: argument) else { return nil } + return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque + case 4: // array + guard argument <= Self.maxContainerCount else { return nil } + var items: [Value] = [] + items.reserveCapacity(Int(min(argument, 64))) + for _ in 0.. (major: UInt8, argument: UInt64)? { + guard index < bytes.count else { return nil } + let head = bytes[index] + index += 1 + let major = head >> 5 + let info = head & 0x1F + switch info { + case 0...23: + return (major, UInt64(info)) + case 24: + return readUInt(width: 1).map { (major, $0) } + case 25: + return readUInt(width: 2).map { (major, $0) } + case 26: + return readUInt(width: 4).map { (major, $0) } + case 27: + return readUInt(width: 8).map { (major, $0) } + default: // 28-30 reserved, 31 indefinite + return nil + } + } + + private mutating func readUInt(width: Int) -> UInt64? { + guard bytes.count - index >= width else { return nil } + var value: UInt64 = 0 + for _ in 0.. [UInt8]? { + guard count <= UInt64(bytes.count - index) else { return nil } + let length = Int(count) + let slice = Array(bytes[index..<(index + length)]) + index += length + return slice + } +} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 9f2f8e9b..220e66ef 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -45,6 +45,8 @@ protocol CommandContextProvider: AnyObject { /// Empties the peer's chat (single-writer store intent for `/clear`). func clearPrivateChat(_ peerID: PeerID) func sendPublicRaw(_ content: String) + /// Sends a normal public message (with local echo) to the active channel. + func sendPublicMessage(_ content: String) // MARK: - System Messages func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) @@ -106,6 +108,8 @@ final class CommandProcessor { case "/unfav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) + case "/pay": + return handlePay(args) case "/help": return .success(message: Self.helpText) default: @@ -125,6 +129,7 @@ final class CommandProcessor { /slap @name — slap with a large trout /block @name · /unblock @name /fav @name · /unfav @name — favorites (mesh only) + /pay — send a cashu ecash token in this chat /help — this list """ @@ -331,6 +336,42 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + /// `/pay ` — validates the token decodes, then sends it as + /// the message body in the current chat. Cashu tokens are bearer + /// instruments (whoever redeems first gets the funds), so posting one to + /// a public channel requires an explicit `/pay public` confirm. + /// The app never contacts a mint; it only relays the string. + private func handlePay(_ args: String) -> CommandResult { + var parts = args.trimmed.split(separator: " ").map(String.init) + guard !parts.isEmpty else { + return .success(message: "usage: /pay — paste a cashu token: /pay cashuA…") + } + + let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public" + if confirmedPublic { parts.removeLast() } + + guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else { + return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…") + } + guard let info = CashuTokenDecoder.decode(token, strict: true) else { + return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it") + } + + let summary = info.displayAmount ?? "a cashu token" + + if let peerID = contextProvider?.selectedPrivateChatPeer { + contextProvider?.sendPrivateMessage(token, to: peerID) + return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds") + } + + guard confirmedPublic else { + return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public") + } + + contextProvider?.sendPublicMessage(token) + return .success(message: "sent \(summary) to the public channel — anyone here can redeem it") + } + private func handleFavorite(_ args: String, add: Bool) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index ae9ac224..2f5fd729 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1684,6 +1684,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.sendPublicRaw(content) } + // Send a normal public message (with local echo) to the active channel. + // CommandContextProvider hook for commands that post real messages + // (`/pay`); only called when no private chat is selected. + @MainActor + func sendPublicMessage(_ content: String) { + sendMessage(content) + } + /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index e01887d0..bdda8772 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -7,12 +7,17 @@ // import SwiftUI +#if os(iOS) +import UIKit +#else +import AppKit +#endif struct PaymentChipView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL @ThemedPalette private var palette - + enum PaymentType { case cashu(String) case lightning(String) @@ -35,14 +40,33 @@ struct PaymentChipView: View { return URL(string: link) } } - + + /// The bare `cashuA…`/`cashuB…` bearer string, when this is a Cashu chip. + var cashuToken: String? { + if case .cashu(let link) = self { + return CashuTokenDecoder.bareToken(from: link) + } + return nil + } + + /// Web fallback for redemption when no wallet handles `cashu:` URLs. + /// The token only reaches the site the user's browser loads; the app + /// itself never contacts a mint. + var cashuWebRedeemURL: URL? { + guard let token = cashuToken, + let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else { + return nil + } + return URL(string: "https://redeem.cashu.me/?token=\(enc)") + } + var emoji: String { switch self { case .cashu: "🥜" case .lightning: "⚡" } } - + var label: String { switch self { case .cashu: @@ -52,27 +76,56 @@ struct PaymentChipView: View { } } } - + let paymentType: PaymentType - + /// Decoded once at construction; tokens are capped in size so this is + /// cheap, and rows re-render often enough that lazy decode in `body` + /// would just repeat the work. + private let cashuInfo: CashuTokenDecoder.TokenInfo? + + init(paymentType: PaymentType) { + self.paymentType = paymentType + if case .cashu(let link) = paymentType { + self.cashuInfo = CashuTokenDecoder.decode(link) + } else { + self.cashuInfo = nil + } + } + private var fgColor: Color { palette.primary } private var bgColor: Color { palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12) } private var border: Color { fgColor.opacity(0.25) } - + + /// "500 sat · mint.example.com", degrading to the generic label when the + /// token didn't decode (V4 payloads we can't walk, malformed input…). + private var primaryLabel: String { + guard let info = cashuInfo else { return paymentType.label } + var parts: [String] = [] + if let amount = info.displayAmount { parts.append(amount) } + if let host = info.mintHost { parts.append(host) } + return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ") + } + + private var memoLabel: String? { cashuInfo?.memo } + var body: some View { Button { - #if os(iOS) - if let url = paymentType.url { openURL(url) } - #else - if let url = paymentType.url { NSWorkspace.shared.open(url) } - #endif + primaryAction() } label: { HStack(spacing: 6) { Text(paymentType.emoji) - Text(paymentType.label) - .bitchatFont(size: 12, weight: .semibold) + VStack(alignment: .leading, spacing: 1) { + Text(primaryLabel) + .bitchatFont(size: 12, weight: .semibold) + if let memoLabel { + Text(memoLabel) + .bitchatFont(size: 10) + .opacity(0.7) + .lineLimit(1) + } + } } .padding(.vertical, 6) .padding(.horizontal, 12) @@ -87,13 +140,102 @@ struct PaymentChipView: View { .foregroundColor(fgColor) } .buttonStyle(.plain) + .contextMenu { + if let token = paymentType.cashuToken { + Button { + copyToPasteboard(token) + } label: { + Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc") + } + Button { + redeemCashu() + } label: { + Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass") + } + if let webURL = paymentType.cashuWebRedeemURL { + Button { + openExternalURL(webURL) + } label: { + Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari") + } + } + } + } + .accessibilityLabel(Text(verbatim: accessibilityText)) + } + + private var accessibilityText: String { + var text = "\(paymentType.label): \(primaryLabel)" + if let memoLabel { text += ", \(memoLabel)" } + return text + } + + // MARK: - Actions + + private func primaryAction() { + switch paymentType { + case .cashu: + redeemCashu() + case .lightning: + #if os(iOS) + if let url = paymentType.url { openURL(url) } + #else + if let url = paymentType.url { NSWorkspace.shared.open(url) } + #endif + } + } + + /// Redemption is delegated: try a wallet registered for `cashu:` URLs + /// first, then fall back to the web redemption page. Uses the platform + /// opener directly (not the `openURL` environment) because the message + /// list overrides that action for cashu/lightning schemes without a + /// fallback path. + private func redeemCashu() { + let walletURL = paymentType.url + let webURL = paymentType.cashuWebRedeemURL + #if os(iOS) + if let walletURL { + UIApplication.shared.open(walletURL, options: [:]) { accepted in + if !accepted, let webURL { + UIApplication.shared.open(webURL) + } + } + } else if let webURL { + UIApplication.shared.open(webURL) + } + #else + if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil { + NSWorkspace.shared.open(walletURL) + } else if let webURL { + NSWorkspace.shared.open(webURL) + } else if let walletURL { + NSWorkspace.shared.open(walletURL) + } + #endif + } + + private func openExternalURL(_ url: URL) { + #if os(iOS) + UIApplication.shared.open(url) + #else + NSWorkspace.shared.open(url) + #endif + } + + private func copyToPasteboard(_ string: String) { + #if os(iOS) + UIPasteboard.general.string = string + #else + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + #endif } } #Preview { let cashuLink = "https://example.com/cashu" let lightningLink = "https://example.com/lightning" - + List { HStack { PaymentChipView(paymentType: .cashu(cashuLink)) diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index bd653ec9..c684d3f1 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -21,17 +21,20 @@ extension String { return current >= threshold } - // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. + // Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare + // bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI + // form matches too — the token embedded after the scheme is the match. func extractCashuLinks(max: Int = 3) -> [String] { let regex = MessageFormattingEngine.Patterns.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] - for m in regex.matches(in: self, range: range) { - if m.numberOfRanges > 0 { - let token = ns.substring(with: m.range(at: 0)) - let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token - found.append("cashu:\(enc)") + for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 { + let token = ns.substring(with: m.range(at: 0)) + // Dedup: repeated tokens are one bearer instrument (and duplicate + // ForEach IDs) — one chip is enough. + if !found.contains(token) { + found.append(token) if found.count >= max { break } } } diff --git a/bitchatTests/CashuTokenDecoderTests.swift b/bitchatTests/CashuTokenDecoderTests.swift new file mode 100644 index 00000000..890a7eeb --- /dev/null +++ b/bitchatTests/CashuTokenDecoderTests.swift @@ -0,0 +1,350 @@ +// +// CashuTokenDecoderTests.swift +// bitchatTests +// +// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4 +// CBOR traversal, URI normalization, detection ranges, and adversarial +// (truncated / garbage / huge) input. The decoder renders attacker-controlled +// message content, so "never crash" matters as much as "decode correctly". +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +@testable import bitchat + +struct CashuTokenDecoderTests { + + // MARK: - Token Builders + + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func makeV3Token( + entries: [(mint: String, amounts: [Int])], + unit: String? = "sat", + memo: String? = nil + ) -> String { + var json: [String: Any] = [ + "token": entries.map { entry in + [ + "mint": entry.mint, + "proofs": entry.amounts.map { + ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any] + } + ] as [String: Any] + } + ] + if let unit { json["unit"] = unit } + if let memo { json["memo"] = memo } + let data = try! JSONSerialization.data(withJSONObject: json) + return "cashuA" + base64URL(data) + } + + /// Tiny deterministic CBOR encoder (definite lengths only) for building + /// V4 test tokens without depending on the decoder under test. + private enum CBOREncode { + static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { + switch value { + case 0...23: + return [(major << 5) | UInt8(value)] + case 24...0xFF: + return [(major << 5) | 24, UInt8(value)] + case 0x100...0xFFFF: + return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + default: + return [(major << 5) | 26, + UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + } + } + static func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + static func text(_ s: String) -> [UInt8] { + let utf8 = Array(s.utf8) + return head(3, UInt64(utf8.count)) + utf8 + } + static func array(_ items: [[UInt8]]) -> [UInt8] { + head(4, UInt64(items.count)) + items.flatMap { $0 } + } + static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { + head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } + } + } + + private func makeV4Token( + mint: String = "https://mint.example.com", + unit: String = "sat", + memo: String? = nil, + amounts: [UInt64] = [1, 4] + ) -> String { + var pairs: [(String, [UInt8])] = [ + ("m", CBOREncode.text(mint)), + ("u", CBOREncode.text(unit)) + ] + if let memo { pairs.append(("d", CBOREncode.text(memo))) } + let proofs = amounts.map { amount in + CBOREncode.map([ + ("a", CBOREncode.uint(amount)), + ("s", CBOREncode.text("secret")), + ("c", CBOREncode.bytes([0x02, 0xAB, 0xCD])) + ]) + } + pairs.append(("t", CBOREncode.array([ + CBOREncode.map([ + ("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])), + ("p", CBOREncode.array(proofs)) + ]) + ]))) + return "cashuB" + base64URL(Data(CBOREncode.map(pairs))) + } + + // MARK: - V3 Decode + + @Test func v3DecodeValidToken() { + let token = makeV3Token( + entries: [("https://mint.example.com", [2, 8])], + unit: "sat", + memo: "thanks!" + ) + let info = CashuTokenDecoder.decode(token) + #expect(info != nil) + #expect(info?.version == "A") + #expect(info?.amount == 10) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "thanks!") + #expect(info?.displayAmount == "10 sat") + } + + @Test func v3AmountSumsAcrossEntriesAndProofs() { + let token = makeV3Token(entries: [ + ("https://a.mint.example", [1, 2, 4]), + ("https://b.mint.example", [8, 16]) + ]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.amount == 31) + // First mint wins for the display host + #expect(info?.mintHost == "a.mint.example") + } + + @Test func v3MissingUnitDefaultsToSatForDisplay() { + let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil) + let info = CashuTokenDecoder.decode(token) + #expect(info?.unit == nil) + #expect(info?.displayAmount == "5 sat") + } + + @Test func v3RejectsNonsenseAmounts() { + // Negative and absurd amounts must not poison the sum + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [ + ["amount": -5, "id": "x", "secret": "s", "C": "c"], + ["amount": 3, "id": "x", "secret": "s", "C": "c"] + ] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == 3) + } + + @Test func v3MemoIsSanitizedForDisplay() { + let token = makeV3Token( + entries: [("https://mint.example.com", [1])], + memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300) + ) + let memo = CashuTokenDecoder.decode(token)?.memo + #expect(memo != nil) + #expect(memo?.contains("\n") == false) + #expect(memo?.contains("\u{0007}") == false) + #expect((memo?.count ?? 0) <= 80) + } + + // MARK: - V4 (CBOR) Decode + + @Test func v4DecodeValidToken() { + let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == 21) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "Thank you") + } + + @Test func v4UnparseableCBORDegradesToGenericToken() { + // Valid base64 payload, but not CBOR we can walk: still a token, + // rendered as a generic chip with no amount. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == nil) + #expect(info?.mintHost == nil) + } + + // MARK: - Strict Mode (used by the /pay SEND path) + + @Test func strictAcceptsValidV3WithPositiveAmount() { + let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "A") + #expect(info?.amount == 10) + } + + @Test func strictAcceptsValidDefiniteLengthV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "B") + #expect(info?.amount == 21) + } + + @Test func strictRejectsUnwalkableV4() { + // Valid base64, but not CBOR we can walk: permissive mode returns a + // generic chip, strict mode refuses it. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + #expect(CashuTokenDecoder.decode(token)?.version == "B") + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + @Test func strictRejectsTruncatedV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + // Lop off the tail of the base64 payload — CBOR can no longer be walked. + let truncated = String(token.prefix(token.count - 12)) + #expect(CashuTokenDecoder.decode(truncated, strict: true) == nil) + } + + @Test func strictRejectsAmountlessToken() { + // A well-formed V3 token that carries no positive proof amount. + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == nil) + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + // MARK: - URI Form and Normalization + + @Test func uriFormsDecode() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] { + #expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)") + #expect(CashuTokenDecoder.decode(wrapped)?.amount == 7) + } + } + + @Test func percentEncodedURIDecodes() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + #expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7) + } + + @Test func bareTokenRejectsNonTokens() { + #expect(CashuTokenDecoder.bareToken(from: "hello world") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short + } + + // MARK: - Adversarial Input (never crash, fail closed) + + @Test func truncatedTokensNeverCrash() { + let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo") + let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8]) + for token in [v3, v4] { + for length in stride(from: 0, to: token.count, by: 3) { + _ = CashuTokenDecoder.decode(String(token.prefix(length))) + } + } + // Truncating the payload must not produce a phantom V3 summary + #expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil) + } + + @Test func garbagePayloadsNeverCrash() { + var rng = SystemRandomNumberGenerator() + for _ in 0..<200 { + let length = Int.random(in: 0..<600, using: &rng) + let junk = Data((0.. [UInt8] { + switch value { + case 0...23: return [(major << 5) | UInt8(value)] + case 24...0xFF: return [(major << 5) | 24, UInt8(value)] + default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + } + } + func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) } + func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } } + func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } } + + let proofs = [UInt64(1), 4, 16].map { amount in + map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))]) + } + let cbor = map([ + ("m", text("https://mint.example.com")), + ("u", text("sat")), + ("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])])) + ]) + let b64 = Data(cbor).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "cashuB" + b64 + }() + + @MainActor + private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor { + CommandProcessor( + contextProvider: context, + meshService: MockTransport(), + identityManager: MockIdentityManager(MockKeychain()) + ) + } + @MainActor private func withSelectedChannel( _ channel: ChannelID, @@ -477,6 +644,11 @@ private final class MockCommandContextProvider: CommandContextProvider { sentPublicRawMessages.append(content) } + private(set) var sentPublicMessages: [String] = [] + func sendPublicMessage(_ content: String) { + sentPublicMessages.append(content) + } + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { localPrivateSystemMessages.append((content, peerID)) }