diff --git a/bitchat/Services/CashuTokenDecoder.swift b/bitchat/Services/CashuTokenDecoder.swift index 78c3b3c2..87dfee02 100644 --- a/bitchat/Services/CashuTokenDecoder.swift +++ b/bitchat/Services/CashuTokenDecoder.swift @@ -67,23 +67,45 @@ enum CashuTokenDecoder { } /// Decodes a token (raw or `cashu:` URI form) into a display summary. - /// V3 tokens must parse as JSON; V4 tokens whose CBOR we cannot walk - /// still return a generic `TokenInfo` (version "B", no amount) because - /// the payload may use encodings this minimal reader doesn't support. - static func decode(_ raw: String) -> TokenInfo? { + /// + /// 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": - return decodeV3(payload) + info = decodeV3(payload) case "B": - return decodeV4(payload) ?? TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil) + 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 diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 81337fc4..220e66ef 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -353,8 +353,8 @@ final class CommandProcessor { 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) else { - return .error(message: "couldn't decode that cashu token — not sending it") + 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" diff --git a/bitchatTests/CashuTokenDecoderTests.swift b/bitchatTests/CashuTokenDecoderTests.swift index 6fa0aefa..890a7eeb 100644 --- a/bitchatTests/CashuTokenDecoderTests.swift +++ b/bitchatTests/CashuTokenDecoderTests.swift @@ -188,6 +188,50 @@ struct CashuTokenDecoderTests { #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() { diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 030c1d8f..1937174e 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -442,6 +442,45 @@ struct CommandProcessorTests { #expect(context.sentPrivateMessages.isEmpty) } + @MainActor + @Test func payRejectsTruncatedOrJunkV4Token() { + let context = MockCommandContextProvider() + context.selectedPrivateChatPeer = PeerID(str: "abcd1234abcd1234") + let processor = makePayProcessor(context: context) + + // Truncated V4 (definite-length CBOR can no longer be walked) and + // pure base64 junk under the cashuB prefix must both be refused. + let truncatedV4 = String(Self.validV4Token.prefix(Self.validV4Token.count - 12)) + let junkV4 = "cashuB" + String(repeating: "Q", count: 40) + for bad in ["/pay \(truncatedV4)", "/pay \(junkV4)"] { + switch processor.process(bad) { + case .error(let message): + #expect(message.contains("invalid cashu token") == true) + default: + Issue.record("Expected error for \(bad)") + } + } + #expect(context.sentPrivateMessages.isEmpty) + #expect(context.sentPublicMessages.isEmpty) + } + + @MainActor + @Test func paySendsValidDefiniteLengthV4Token() { + let context = MockCommandContextProvider() + let peerID = PeerID(str: "abcd1234abcd1234") + context.selectedPrivateChatPeer = peerID + let processor = makePayProcessor(context: context) + + switch processor.process("/pay \(Self.validV4Token)") { + case .success(let message): + #expect(message?.contains("21 sat") == true) + default: + Issue.record("Expected success result for valid V4 token") + } + #expect(context.sentPrivateMessages.count == 1) + #expect(context.sentPrivateMessages.first?.content == Self.validV4Token) + } + /// 21-sat single-mint V3 token (proofs of 1+4+16). private static let validV3Token: String = { let json: [String: Any] = [ @@ -459,6 +498,36 @@ struct CommandProcessorTests { return "cashuA" + b64 }() + /// 21-sat single-mint definite-length V4 (CBOR) token (proofs of 1+4+16). + private static let validV4Token: String = { + 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)] + 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(