diff --git a/bitchat/Nostr/Base64URLCoding.swift b/bitchat/Nostr/Base64URLCoding.swift deleted file mode 100644 index bbc04ea6..00000000 --- a/bitchat/Nostr/Base64URLCoding.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -enum Base64URLCoding { - static func encode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - static func decode(_ string: String) -> Data? { - var base64 = string - let padding = (4 - (base64.count % 4)) % 4 - if padding > 0 { - base64 += String(repeating: "=", count: padding) - } - base64 = base64 - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - return Data(base64Encoded: base64) - } -} diff --git a/bitchat/Nostr/NostrEmbeddedBitChat.swift b/bitchat/Nostr/NostrEmbeddedBitChat.swift index 5ee5644c..4d736d92 100644 --- a/bitchat/Nostr/NostrEmbeddedBitChat.swift +++ b/bitchat/Nostr/NostrEmbeddedBitChat.swift @@ -28,7 +28,7 @@ struct NostrEmbeddedBitChat { ) guard let data = packet.toBinaryData() else { return nil } - return "bitchat1:" + base64URLEncode(data) + return "bitchat1:" + Base64URLCoding.encode(data) } /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs. @@ -51,7 +51,7 @@ struct NostrEmbeddedBitChat { ) guard let data = packet.toBinaryData() else { return nil } - return "bitchat1:" + base64URLEncode(data) + return "bitchat1:" + Base64URLCoding.encode(data) } /// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs). @@ -72,7 +72,7 @@ struct NostrEmbeddedBitChat { ) guard let data = packet.toBinaryData() else { return nil } - return "bitchat1:" + base64URLEncode(data) + return "bitchat1:" + Base64URLCoding.encode(data) } /// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs). @@ -94,7 +94,7 @@ struct NostrEmbeddedBitChat { ) guard let data = packet.toBinaryData() else { return nil } - return "bitchat1:" + base64URLEncode(data) + return "bitchat1:" + Base64URLCoding.encode(data) } private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID { @@ -110,13 +110,4 @@ struct NostrEmbeddedBitChat { // Fallback: return as-is (expecting 16 hex chars) – caller should pass a valid peer ID return recipientPeerID } - - /// Base64url encode without padding - private static func base64URLEncode(_ data: Data) -> String { - let b64 = data.base64EncodedString() - return b64 - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } } diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index bfef85e2..d83641c9 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -1,3 +1,4 @@ +import BitFoundation import BitLogger import Foundation import CryptoKit diff --git a/bitchat/Services/CashuTokenDecoder.swift b/bitchat/Services/CashuTokenDecoder.swift index 87dfee02..14a54071 100644 --- a/bitchat/Services/CashuTokenDecoder.swift +++ b/bitchat/Services/CashuTokenDecoder.swift @@ -14,6 +14,7 @@ // For more information, see // +import BitFoundation import Foundation enum CashuTokenDecoder { @@ -81,7 +82,7 @@ enum CashuTokenDecoder { 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 { + guard let payload = Base64URLCoding.decode(String(token.dropFirst(6))), !payload.isEmpty else { return nil } let info: TokenInfo? @@ -108,20 +109,6 @@ enum CashuTokenDecoder { 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? { diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index ae983cca..a820e1e8 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation /// QR verification scaffolding: schema, signing, and basic challenge/response helpers. @@ -85,7 +86,7 @@ final class VerificationService { let ts = Int64(Date().timeIntervalSince1970) var nonce = Data(count: 16) _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } - let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + let nonceB64 = Base64URLCoding.encode(nonce) let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "") let msg = payload.canonicalBytes() guard let sig = transport.noiseSignData(msg) else { return nil } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/Base64URLCoding.swift b/localPackages/BitFoundation/Sources/BitFoundation/Base64URLCoding.swift new file mode 100644 index 00000000..cd1f9869 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/Base64URLCoding.swift @@ -0,0 +1,38 @@ +// +// Base64URLCoding.swift +// BitFoundation +// +// Single implementation of base64url (RFC 4648 §5) used across the app — +// Nostr embeddings, Cashu tokens, and verification QR payloads all share +// these helpers so padding and alphabet handling cannot drift per call site. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +public enum Base64URLCoding { + /// Encode data as base64url without padding. + public static func encode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Decode a base64url string, accepting both padded and unpadded forms + /// (external producers, e.g. Cashu wallets, emit both). + public static func decode(_ string: String) -> Data? { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Normalize padding: strip any '=' then re-pad to a multiple of 4. + base64 = base64.replacingOccurrences(of: "=", with: "") + let remainder = base64.count % 4 + // No valid base64 payload has length ≡ 1 (mod 4). + if remainder == 1 { return nil } + if remainder > 0 { base64 += String(repeating: "=", count: 4 - remainder) } + return Data(base64Encoded: base64) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/Base64URLCodingTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/Base64URLCodingTests.swift new file mode 100644 index 00000000..4e29a759 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/Base64URLCodingTests.swift @@ -0,0 +1,62 @@ +// +// Base64URLCodingTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct Base64URLCodingTests { + @Test + func encodeUsesURLAlphabetWithoutPadding() { + // 0xFF 0xEF is "/+8=" in standard base64: exercises both + // substituted characters and padding removal. + #expect(Base64URLCoding.encode(Data([0xFF, 0xEF])) == "_-8") + #expect(Base64URLCoding.encode(Data("Man".utf8)) == "TWFu") + #expect(Base64URLCoding.encode(Data("Ma".utf8)) == "TWE") + #expect(Base64URLCoding.encode(Data("M".utf8)) == "TQ") + #expect(Base64URLCoding.encode(Data()) == "") + } + + @Test + func decodeAcceptsUnpaddedInput() { + #expect(Base64URLCoding.decode("_-8") == Data([0xFF, 0xEF])) + #expect(Base64URLCoding.decode("TWFu") == Data("Man".utf8)) + #expect(Base64URLCoding.decode("TWE") == Data("Ma".utf8)) + #expect(Base64URLCoding.decode("TQ") == Data("M".utf8)) + #expect(Base64URLCoding.decode("") == Data()) + } + + @Test + func decodeAcceptsPaddedInput() { + // External producers (e.g. Cashu wallets) emit padded forms too. + #expect(Base64URLCoding.decode("_-8=") == Data([0xFF, 0xEF])) + #expect(Base64URLCoding.decode("TWE=") == Data("Ma".utf8)) + #expect(Base64URLCoding.decode("TQ==") == Data("M".utf8)) + } + + @Test + func decodeRejectsInvalidInput() { + // Length ≡ 1 (mod 4) can never be valid base64. + #expect(Base64URLCoding.decode("TQQQQ") == nil) + // Characters outside the base64url alphabet. + #expect(Base64URLCoding.decode("!!!") == nil) + #expect(Base64URLCoding.decode("TW Fu") == nil) + } + + @Test + func roundTripsAllPaddingLengths() { + for length in 0..<16 { + let data = Data((0..