From 8b3f7b65a5d9bb57c2fb2c0458582230539f4d51 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 11:15:08 -0400 Subject: [PATCH] fix(transactions): mask a server-issued intent ID in logs until it settles A payment request's payment_intent_id can be spent by anyone who learns it before the share is paid (paymentrequest_service.proto:192). TransactionService.submit logged every intent ID in full. A caller can now mark the ID as sensitive through transfer(intentIDIsSensitive:); submit then logs only its last six base58 characters before submitting and on failure, and the full ID once the intent succeeds. Other intents log as before. --- .../Payments API/Client+Transaction.swift | 6 ++-- .../Payments API/Intents/IntentLogID.swift | 28 +++++++++++++++++++ .../Services/TransactionService.swift | 17 +++++------ .../FlipcashCoreTests/IntentLogIDTests.swift | 26 +++++++++++++++++ 4 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentLogID.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/IntentLogIDTests.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift index 391620696..18853faa7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift @@ -45,7 +45,8 @@ extension Client { ) } - public func transfer(exchangedFiat: ExchangedFiat, verifiedState: VerifiedState, owner: AccountCluster, destination: PublicKey, destinationOwner: PublicKey? = nil, chatMetadata: ChatPaymentMetadata? = nil, rendezvous: PublicKey) async throws { + /// Submits a transfer under `rendezvous` as its intent ID; pass `intentIDIsSensitive` for a server-issued ID that anyone reading the logs could spend before it settles. + public func transfer(exchangedFiat: ExchangedFiat, verifiedState: VerifiedState, owner: AccountCluster, destination: PublicKey, destinationOwner: PublicKey? = nil, chatMetadata: ChatPaymentMetadata? = nil, rendezvous: PublicKey, intentIDIsSensitive: Bool = false) async throws { let appMetadata = try chatMetadata?.serializedAppMetadata() _ = try await withCheckedThrowingContinuation { c in transactionService.transfer( @@ -56,7 +57,8 @@ extension Client { destinationOwner: destinationOwner, appMetadata: appMetadata, owner: owner.authority.keyPair, - rendezvous: rendezvous + rendezvous: rendezvous, + intentIDIsSensitive: intentIDIsSensitive ) { c.resume(with: $0) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentLogID.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentLogID.swift new file mode 100644 index 000000000..4ed1fac87 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentLogID.swift @@ -0,0 +1,28 @@ +// +// IntentLogID.swift +// FlipcashCore +// + +import Foundation + +/// How an intent ID appears in logs: in full, or masked to its last six base58 characters while it is still usable. +struct IntentLogID: Sendable, Equatable { + + /// Characters of the base58 ID kept when masked. + static let suffixLength = 6 + + let id: PublicKey + + /// Whether anyone who reads the ID before it settles could spend it, as with a payment request's `payment_intent_id`. + let isSensitive: Bool + + /// The ID to log before the intent settles or after it fails. + var pending: String { + isSensitive ? "…" + String(id.base58.suffix(Self.suffixLength)) : id.base58 + } + + /// The ID to log once the intent has succeeded. + var settled: String { + id.base58 + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift index a81e7d0ca..204bb5e53 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift @@ -56,7 +56,7 @@ final class TransactionService: Sendable { // MARK: - Transfer - - func transfer(exchangedFiat: ExchangedFiat, verifiedState: VerifiedState, sourceCluster: AccountCluster, destination: PublicKey, destinationOwner: PublicKey? = nil, appMetadata: Data? = nil, owner: KeyPair, rendezvous: PublicKey, completion: @Sendable @escaping (Result<(), Error>) -> Void) { + func transfer(exchangedFiat: ExchangedFiat, verifiedState: VerifiedState, sourceCluster: AccountCluster, destination: PublicKey, destinationOwner: PublicKey? = nil, appMetadata: Data? = nil, owner: KeyPair, rendezvous: PublicKey, intentIDIsSensitive: Bool = false, completion: @Sendable @escaping (Result<(), Error>) -> Void) { logger.info("Sending transfer") let intent = IntentTransfer( @@ -69,7 +69,7 @@ final class TransactionService: Sendable { appMetadata: appMetadata ) - submit(intent: intent, owner: owner) { result in + submit(intent: intent, owner: owner, intentIDIsSensitive: intentIDIsSensitive) { result in switch result { case .success(_): logger.info("Transfer succeeded") @@ -682,8 +682,9 @@ final class TransactionService: Sendable { // MARK: - Submit - - private func submit(intent: T, owner: KeyPair, deviceToken: Data? = nil, completion: @Sendable @escaping (Result) -> Void) where T: IntentType { - logger.info("Submitting intent", metadata: ["type": "\(T.self)", "intentId": "\(intent.id.base58)"]) + private func submit(intent: T, owner: KeyPair, deviceToken: Data? = nil, intentIDIsSensitive: Bool = false, completion: @Sendable @escaping (Result) -> Void) where T: IntentType { + let logID = IntentLogID(id: intent.id, isSensitive: intentIDIsSensitive) + logger.info("Submitting intent", metadata: ["type": "\(T.self)", "intentId": "\(logID.pending)"]) // `IntentType` is a reference type that is not `Sendable`; the response // handler mutates it (`apply(parameters:)`) within the single-threaded @@ -712,14 +713,14 @@ final class TransactionService: Sendable { logger.info("Received server parameters, submitting signatures", metadata: [ "type": "\(T.self)", "paramCount": "\(parameters.serverParameters.count)", - "intentId": "\(intent.id.base58)" + "intentId": "\(logID.pending)" ]) } catch { logger.error("Received server parameters but failed to apply them", metadata: [ "type": "\(T.self)", "paramCount": "\(parameters.serverParameters.count)", - "intentId": "\(intent.id.base58)", + "intentId": "\(logID.pending)", "error": "\(error)" ]) reference.cancel() @@ -732,7 +733,7 @@ final class TransactionService: Sendable { logger.info("Intent submitted successfully", metadata: [ "type": "\(T.self)", "code": "\(success.code.rawValue)", - "intentId": "\(intent.id.base58)" + "intentId": "\(logID.settled)" ]) reference.cancel() completion(.success(intent)) @@ -755,7 +756,7 @@ final class TransactionService: Sendable { "type": "\(T.self)", "code": "\(error.code)", "detailCount": "\(error.errorDetails.count)", - "intentId": "\(intent.id.base58)", + "intentId": "\(logID.pending)", "invalidSignatures": "\(invalidSignatures)" ]) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/IntentLogIDTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/IntentLogIDTests.swift new file mode 100644 index 000000000..761a24169 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/IntentLogIDTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("IntentLogID") +struct IntentLogIDTests { + + private let id = PublicKey.generate()! + + @Test("A sensitive intent ID logs only its last six characters until it succeeds") + func sensitiveMasksUntilSettled() { + let logID = IntentLogID(id: id, isSensitive: true) + + #expect(logID.pending == "…" + String(id.base58.suffix(6))) + #expect(!logID.pending.contains(id.base58)) + #expect(logID.settled == id.base58) + } + + @Test("A normal intent ID logs in full before and after it settles") + func normalLogsInFull() { + let logID = IntentLogID(id: id, isSensitive: false) + + #expect(logID.pending == id.base58) + #expect(logID.settled == id.base58) + } +}