mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:45:22 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -67,23 +67,45 @@ enum CashuTokenDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
|
/// 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
|
/// In the default (permissive) mode this is for *rendering*: V3 tokens
|
||||||
/// the payload may use encodings this minimal reader doesn't support.
|
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
|
||||||
static func decode(_ raw: String) -> TokenInfo? {
|
/// 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 }
|
guard let token = bareToken(from: raw) else { return nil }
|
||||||
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
|
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
|
||||||
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
|
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
let info: TokenInfo?
|
||||||
switch version {
|
switch version {
|
||||||
case "A":
|
case "A":
|
||||||
return decodeV3(payload)
|
info = decodeV3(payload)
|
||||||
case "B":
|
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:
|
default:
|
||||||
return nil
|
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
|
// MARK: - Base64url
|
||||||
|
|||||||
@@ -353,8 +353,8 @@ final class CommandProcessor {
|
|||||||
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
|
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…")
|
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
|
||||||
}
|
}
|
||||||
guard let info = CashuTokenDecoder.decode(token) else {
|
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
|
||||||
return .error(message: "couldn't decode that cashu token — not sending it")
|
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"
|
let summary = info.displayAmount ?? "a cashu token"
|
||||||
|
|||||||
@@ -188,6 +188,50 @@ struct CashuTokenDecoderTests {
|
|||||||
#expect(info?.mintHost == 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
|
// MARK: - URI Form and Normalization
|
||||||
|
|
||||||
@Test func uriFormsDecode() {
|
@Test func uriFormsDecode() {
|
||||||
|
|||||||
@@ -442,6 +442,45 @@ struct CommandProcessorTests {
|
|||||||
#expect(context.sentPrivateMessages.isEmpty)
|
#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).
|
/// 21-sat single-mint V3 token (proofs of 1+4+16).
|
||||||
private static let validV3Token: String = {
|
private static let validV3Token: String = {
|
||||||
let json: [String: Any] = [
|
let json: [String: Any] = [
|
||||||
@@ -459,6 +498,36 @@ struct CommandProcessorTests {
|
|||||||
return "cashuA" + b64
|
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
|
@MainActor
|
||||||
private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor {
|
private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor {
|
||||||
CommandProcessor(
|
CommandProcessor(
|
||||||
|
|||||||
Reference in New Issue
Block a user