mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 19:05:22 +00:00
Harden public intake bounds against untrusted growth (#1451)
* Bound public rate-limit buckets against attacker-keyed growth. Keep the NIP-13 PoW sender bypass, skip content-bucket minting on sender reject, and evict idle/oldest entries at a hard cap. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop Cashu-looking text from skipping long-message guards. Oversized public content always collapses and takes the plain formatting path so remote tokens cannot force layout/regex DoS. Co-authored-by: Cursor <cursoragent@cursor.com> * Cap teleported geohash participant markers. Bound the set with FIFO eviction, clear it on channel switch, and prune markers that leave the visible participant list. Co-authored-by: Cursor <cursoragent@cursor.com> * Bound untrusted Nostr relay frames and event tags. Reject oversized inbound messages before JSON parse, cap tag arrays/values at decode, and stop logging raw tag contents. Co-authored-by: Cursor <cursoragent@cursor.com> * Fail soft when Noise handshake state is unexpectedly missing. Replace the initiator startHandshake force unwrap with a guard that throws invalidState instead of crashing. Co-authored-by: Cursor <cursoragent@cursor.com> * Cap geohash nickname cache from remote Nostr events. FIFO-evict at capacity, clear on channel switch, and prune nicknames that leave the visible participant list. Co-authored-by: Cursor <cursoragent@cursor.com> * Avoid overlapping exclusive access in the rate limiter. Make bucket helpers static so inout dictionary updates do not conflict with a mutating call on self. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix rate-limiter tests for mutating allow under #expect. Call allow outside the macro so Swift Testing does not capture an immutable copy of the struct. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop unused WebSocket data helper; reset rate limiter on panic wipe. dataWithinInboundLimit replaced the unbounded path, and panic clear should not leave public intake buckets behind. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
|
|||||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||||
@Published private(set) var teleportedGeo: Set<String> = []
|
@Published private(set) var teleportedGeo: Set<String> = []
|
||||||
|
|
||||||
|
private let teleportedGeoCapacity: Int
|
||||||
|
private var teleportedGeoOrder: [String] = []
|
||||||
|
private let geoNicknameCapacity: Int
|
||||||
|
private var geoNicknameOrder: [String] = []
|
||||||
|
|
||||||
|
init(
|
||||||
|
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
|
||||||
|
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
|
||||||
|
) {
|
||||||
|
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
|
||||||
|
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
|
||||||
|
}
|
||||||
|
|
||||||
func setCurrentGeohash(_ geohash: String?) {
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
currentGeohash = geohash?.lowercased()
|
let normalized = geohash?.lowercased()
|
||||||
|
if currentGeohash != normalized {
|
||||||
|
// Presence markers are scoped to the active geohash channel.
|
||||||
|
clearTeleportedGeo()
|
||||||
|
clearGeoNicknames()
|
||||||
|
}
|
||||||
|
currentGeohash = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
guard geoNicknameCapacity > 0 else {
|
||||||
|
clearGeoNicknames()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
if geoNicknames[key] != nil {
|
||||||
|
geoNicknames[key] = nickname
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
|
||||||
|
geoNicknameOrder.removeFirst()
|
||||||
|
geoNicknames.removeValue(forKey: oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
geoNicknames[key] = nickname
|
||||||
|
geoNicknameOrder.append(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
geoNicknames = Dictionary(
|
guard geoNicknameCapacity > 0 else {
|
||||||
uniqueKeysWithValues: nicknames.map { key, value in
|
clearGeoNicknames()
|
||||||
(key.lowercased(), value)
|
return
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
var seen: Set<String> = []
|
||||||
|
var ordered: [String] = []
|
||||||
|
var normalized: [String: String] = [:]
|
||||||
|
for (key, value) in nicknames {
|
||||||
|
let lower = key.lowercased()
|
||||||
|
guard seen.insert(lower).inserted else { continue }
|
||||||
|
ordered.append(lower)
|
||||||
|
normalized[lower] = value
|
||||||
|
}
|
||||||
|
if ordered.count > geoNicknameCapacity {
|
||||||
|
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||||
|
ordered = kept
|
||||||
|
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
|
||||||
|
normalized[key].map { (key, $0) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
geoNicknameOrder = ordered
|
||||||
|
geoNicknames = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearGeoNicknames() {
|
func clearGeoNicknames() {
|
||||||
geoNicknames.removeAll()
|
geoNicknames.removeAll()
|
||||||
|
geoNicknameOrder.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func retainGeoNicknames(keeping pubkeys: Set<String>) {
|
||||||
|
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
|
||||||
|
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func markTeleported(_ pubkeyHex: String) {
|
func markTeleported(_ pubkeyHex: String) {
|
||||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
guard teleportedGeoCapacity > 0 else {
|
||||||
|
clearTeleportedGeo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
guard !teleportedGeo.contains(key) else { return }
|
||||||
|
|
||||||
|
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
|
||||||
|
teleportedGeoOrder.removeFirst()
|
||||||
|
teleportedGeo.remove(oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
teleportedGeo.insert(key)
|
||||||
|
teleportedGeoOrder.append(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTeleported(_ pubkeyHex: String) {
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
let key = pubkeyHex.lowercased()
|
||||||
|
teleportedGeo.remove(key)
|
||||||
|
teleportedGeoOrder.removeAll { $0 == key }
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
guard teleportedGeoCapacity > 0 else {
|
||||||
|
clearTeleportedGeo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen: Set<String> = []
|
||||||
|
var ordered: [String] = []
|
||||||
|
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
|
||||||
|
seen.insert(key)
|
||||||
|
ordered.append(key)
|
||||||
|
}
|
||||||
|
if ordered.count > teleportedGeoCapacity {
|
||||||
|
ordered = Array(ordered.suffix(teleportedGeoCapacity))
|
||||||
|
}
|
||||||
|
teleportedGeoOrder = ordered
|
||||||
|
teleportedGeo = Set(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||||
|
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||||
|
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTeleportedGeo() {
|
func clearTeleportedGeo() {
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
|
teleportedGeoOrder.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
currentGeohash = nil
|
currentGeohash = nil
|
||||||
geoNicknames.removeAll()
|
geoNicknames.removeAll()
|
||||||
|
geoNicknameOrder.removeAll()
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
|
teleportedGeoOrder.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Only initiator writes the first message
|
// Only initiator writes the first message
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
let message = try handshakeState!.writeMessage()
|
guard let handshake = handshakeState else {
|
||||||
|
throw NoiseSessionError.invalidState
|
||||||
|
}
|
||||||
|
let message = try handshake.writeMessage()
|
||||||
sentHandshakeMessages.append(message)
|
sentHandshakeMessages.append(message)
|
||||||
return message
|
return message
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -701,6 +701,10 @@ struct NostrEvent: Codable {
|
|||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard Self.isWithinInboundTagLimits(tags) else {
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
self.id = dict["id"] as? String ?? ""
|
self.id = dict["id"] as? String ?? ""
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
self.created_at = createdAt
|
self.created_at = createdAt
|
||||||
@@ -710,6 +714,21 @@ struct NostrEvent: Codable {
|
|||||||
self.sig = dict["sig"] as? String
|
self.sig = dict["sig"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounds untrusted relay tag arrays so attackers cannot force large
|
||||||
|
/// allocations or expensive joins on the inbound hot path.
|
||||||
|
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
|
||||||
|
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
|
||||||
|
|
||||||
|
for tag in tags {
|
||||||
|
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
|
||||||
|
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||||
let (eventId, eventIdHash) = try calculateEventId()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|
||||||
|
|||||||
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
|
|||||||
case notice(String)
|
case notice(String)
|
||||||
|
|
||||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||||
guard let data = message.data,
|
guard let data = message.dataWithinInboundLimit,
|
||||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||||
array.count >= 2,
|
array.count >= 2,
|
||||||
let type = array[0] as? String else {
|
let type = array[0] as? String else {
|
||||||
@@ -1525,11 +1525,19 @@ private enum ParsedInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension URLSessionWebSocketTask.Message {
|
private extension URLSessionWebSocketTask.Message {
|
||||||
var data: Data? {
|
/// Prefer rejecting oversized frames before UTF-8/Data materialization
|
||||||
|
/// where we can (string length), and always before JSON parse.
|
||||||
|
var dataWithinInboundLimit: Data? {
|
||||||
|
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
|
||||||
switch self {
|
switch self {
|
||||||
case .string(let text): text.data(using: .utf8)
|
case .string(let text):
|
||||||
case .data(let data): data
|
guard text.utf8.count <= maxBytes else { return nil }
|
||||||
@unknown default: nil
|
return text.data(using: .utf8)
|
||||||
|
case .data(let data):
|
||||||
|
guard data.count <= maxBytes else { return nil }
|
||||||
|
return data
|
||||||
|
@unknown default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content without special tokens, use plain formatting
|
// For very long content, use plain formatting to avoid expensive
|
||||||
let containsCashu = containsCashuToken(content)
|
// regex/detector work. Cashu presence must not disable this guard.
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
if content.isOversizedForRichFormatting() {
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ enum TransportConfig {
|
|||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
static let meshTimelineCap: Int = 1337
|
static let meshTimelineCap: Int = 1337
|
||||||
static let geoTimelineCap: Int = 1337
|
static let geoTimelineCap: Int = 1337
|
||||||
|
static let geoNicknameParticipantsCap: Int = 1337
|
||||||
static let contentLRUCap: Int = 2000
|
static let contentLRUCap: Int = 2000
|
||||||
static let geoSamplingEventLRUCap: Int = 2000
|
static let geoSamplingEventLRUCap: Int = 2000
|
||||||
|
|
||||||
@@ -81,6 +82,11 @@ enum TransportConfig {
|
|||||||
static let nostrDuplicateEventLogInterval: Int = 50
|
static let nostrDuplicateEventLogInterval: Int = 50
|
||||||
// Sample interval for per-event debug logs on the inbound hot path.
|
// Sample interval for per-event debug logs on the inbound hot path.
|
||||||
static let nostrInboundEventLogInterval: Int = 100
|
static let nostrInboundEventLogInterval: Int = 100
|
||||||
|
// Reject oversized/untrusted relay frames before JSON parse / store.
|
||||||
|
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
|
||||||
|
static let nostrMaxEventTags: Int = 64
|
||||||
|
static let nostrMaxEventTagValues: Int = 16
|
||||||
|
static let nostrMaxEventTagValueBytes: Int = 1024
|
||||||
|
|
||||||
// Conversation store diagnostics (field observability)
|
// Conversation store diagnostics (field observability)
|
||||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||||
@@ -98,6 +104,12 @@ enum TransportConfig {
|
|||||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||||
static let uiContentRateBucketCapacity: Double = 3
|
static let uiContentRateBucketCapacity: Double = 3
|
||||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||||
|
// Bound attacker-keyed bucket maps (sender IDs / content digests).
|
||||||
|
static let uiSenderRateBucketMaxEntries: Int = 2000
|
||||||
|
static let uiContentRateBucketMaxEntries: Int = 2000
|
||||||
|
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
|
||||||
|
// Cap teleported-participant markers so remote events cannot grow the set.
|
||||||
|
static let geoTeleportedParticipantsCap: Int = 1337
|
||||||
|
|
||||||
// UI sleeps/delays
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -71,12 +71,8 @@ final class ChatMessageFormatter {
|
|||||||
let content = message.content
|
let content = message.content
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
let containsCashuEarly: Bool = {
|
|
||||||
let regex = Patterns.quickCashuPresence
|
|
||||||
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
|
||||||
}()
|
|
||||||
|
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
if content.isOversizedForRichFormatting() {
|
||||||
var plainStyle = AttributeContainer()
|
var plainStyle = AttributeContainer()
|
||||||
plainStyle.foregroundColor = baseColor
|
plainStyle.foregroundColor = baseColor
|
||||||
plainStyle.font = isSelf
|
plainStyle.font = isSelf
|
||||||
|
|||||||
@@ -1183,6 +1183,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
identityManager.clearAllIdentityData()
|
identityManager.clearAllIdentityData()
|
||||||
peerIdentityStore.clearAll()
|
peerIdentityStore.clearAll()
|
||||||
locationPresenceStore.reset()
|
locationPresenceStore.reset()
|
||||||
|
publicRateLimiter.reset()
|
||||||
|
|
||||||
// Clear persistent favorites from keychain
|
// Clear persistent favorites from keychain
|
||||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ private extension ChatViewModelBootstrapper {
|
|||||||
viewModel?.objectWillChange.send()
|
viewModel?.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &viewModel.cancellables)
|
.store(in: &viewModel.cancellables)
|
||||||
|
|
||||||
|
viewModel.participantTracker.$visiblePeople
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak viewModel] people in
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
let visible = Set(people.map { $0.id })
|
||||||
|
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
|
||||||
|
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPersistedViewState() {
|
func loadPersistedViewState() {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
|
||||||
|
now.timeIntervalSince(lastRefill) >= idleTTL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var senderBuckets: [String: TokenBucket] = [:]
|
private var senderBuckets: [String: TokenBucket] = [:]
|
||||||
@@ -35,17 +39,26 @@ struct MessageRateLimiter {
|
|||||||
private let senderRefill: Double
|
private let senderRefill: Double
|
||||||
private let contentCapacity: Double
|
private let contentCapacity: Double
|
||||||
private let contentRefill: Double
|
private let contentRefill: Double
|
||||||
|
private let maxSenderBuckets: Int
|
||||||
|
private let maxContentBuckets: Int
|
||||||
|
private let bucketIdleTTL: TimeInterval
|
||||||
|
|
||||||
init(
|
init(
|
||||||
senderCapacity: Double,
|
senderCapacity: Double,
|
||||||
senderRefillPerSec: Double,
|
senderRefillPerSec: Double,
|
||||||
contentCapacity: Double,
|
contentCapacity: Double,
|
||||||
contentRefillPerSec: Double
|
contentRefillPerSec: Double,
|
||||||
|
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
|
||||||
|
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
|
||||||
|
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
|
||||||
) {
|
) {
|
||||||
self.senderCapacity = senderCapacity
|
self.senderCapacity = senderCapacity
|
||||||
self.senderRefill = senderRefillPerSec
|
self.senderRefill = senderRefillPerSec
|
||||||
self.contentCapacity = contentCapacity
|
self.contentCapacity = contentCapacity
|
||||||
self.contentRefill = contentRefillPerSec
|
self.contentRefill = contentRefillPerSec
|
||||||
|
self.maxSenderBuckets = max(1, maxSenderBuckets)
|
||||||
|
self.maxContentBuckets = max(1, maxContentBuckets)
|
||||||
|
self.bucketIdleTTL = bucketIdleTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||||
@@ -58,25 +71,83 @@ struct MessageRateLimiter {
|
|||||||
if powBits >= NostrPoW.rateLimitBypassBits {
|
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||||
senderAllowed = true
|
senderAllowed = true
|
||||||
} else {
|
} else {
|
||||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
var senderBucket = Self.bucket(
|
||||||
|
for: senderKey,
|
||||||
|
in: &senderBuckets,
|
||||||
capacity: senderCapacity,
|
capacity: senderCapacity,
|
||||||
tokens: senderCapacity,
|
|
||||||
refillPerSec: senderRefill,
|
refillPerSec: senderRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxSenderBuckets,
|
||||||
|
idleTTL: bucketIdleTTL,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
senderAllowed = senderBucket.allow(now: now)
|
senderAllowed = senderBucket.allow(now: now)
|
||||||
senderBuckets[senderKey] = senderBucket
|
senderBuckets[senderKey] = senderBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
// Rejected senders must not mint attacker-keyed content entries.
|
||||||
|
guard senderAllowed else { return false }
|
||||||
|
|
||||||
|
var contentBucket = Self.bucket(
|
||||||
|
for: contentKey,
|
||||||
|
in: &contentBuckets,
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
tokens: contentCapacity,
|
|
||||||
refillPerSec: contentRefill,
|
refillPerSec: contentRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxContentBuckets,
|
||||||
|
idleTTL: bucketIdleTTL,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
let contentAllowed = contentBucket.allow(now: now)
|
||||||
contentBuckets[contentKey] = contentBucket
|
contentBuckets[contentKey] = contentBucket
|
||||||
|
|
||||||
return senderAllowed && contentAllowed
|
return contentAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
senderBuckets.removeAll()
|
||||||
|
contentBuckets.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
var bucketCountsForTesting: (sender: Int, content: Int) {
|
||||||
|
(senderBuckets.count, contentBuckets.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static so we can take `inout` on a stored dictionary without overlapping
|
||||||
|
// exclusive access through a mutating method on `self`.
|
||||||
|
private static func bucket(
|
||||||
|
for key: String,
|
||||||
|
in buckets: inout [String: TokenBucket],
|
||||||
|
capacity: Double,
|
||||||
|
refillPerSec: Double,
|
||||||
|
maxBuckets: Int,
|
||||||
|
idleTTL: TimeInterval,
|
||||||
|
now: Date
|
||||||
|
) -> TokenBucket {
|
||||||
|
if let existing = buckets[key] {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
|
||||||
|
return TokenBucket(
|
||||||
|
capacity: capacity,
|
||||||
|
tokens: capacity,
|
||||||
|
refillPerSec: refillPerSec,
|
||||||
|
lastRefill: now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func evictIfNeeded(
|
||||||
|
from buckets: inout [String: TokenBucket],
|
||||||
|
maxBuckets: Int,
|
||||||
|
idleTTL: TimeInterval,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
guard buckets.count >= maxBuckets else { return }
|
||||||
|
|
||||||
|
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
|
||||||
|
guard buckets.count >= maxBuckets else { return }
|
||||||
|
|
||||||
|
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
|
||||||
|
buckets.removeValue(forKey: oldestKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ final class NostrInboundPipeline {
|
|||||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
geoEventLogCount += 1
|
geoEventLogCount += 1
|
||||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
SecureLogger.debug(
|
||||||
|
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct TextMessageView: View {
|
|||||||
// first text line; a fixed top padding left the lock's solid body
|
// first text line; a fixed top padding left the lock's solid body
|
||||||
// hanging below the line's visual center.
|
// hanging below the line's visual center.
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
let isLong = message.content.isLongForDisplay()
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
@@ -103,7 +103,7 @@ struct TextMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
if message.content.isLongForDisplay() {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
Button(labelKey) {
|
Button(labelKey) {
|
||||||
|
|||||||
@@ -21,6 +21,26 @@ extension String {
|
|||||||
return current >= threshold
|
return current >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the message should collapse behind Show more in the UI.
|
||||||
|
/// Length alone decides this — embedding a Cashu-looking token must not
|
||||||
|
/// disable the guard (remote DoS via unbounded layout).
|
||||||
|
func isLongForDisplay(
|
||||||
|
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
|
||||||
|
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
|
||||||
|
) -> Bool {
|
||||||
|
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when rich formatting (regex / link detectors) should be skipped.
|
||||||
|
/// Cashu presence used to exempt oversized content from the plain path;
|
||||||
|
/// that let untrusted input force expensive formatting work.
|
||||||
|
func isOversizedForRichFormatting(
|
||||||
|
lengthThreshold: Int = 4000,
|
||||||
|
tokenThreshold: Int = 1024
|
||||||
|
) -> Bool {
|
||||||
|
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
||||||
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
||||||
// form matches too — the token embedded after the scheme is the match.
|
// form matches too — the token embedded after the scheme is the match.
|
||||||
|
|||||||
@@ -147,6 +147,44 @@ struct AppArchitectureTests {
|
|||||||
#expect(store.teleportedGeo.isEmpty)
|
#expect(store.teleportedGeo.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
|
||||||
|
@MainActor
|
||||||
|
func locationPresenceStoreBoundsTeleportedParticipants() {
|
||||||
|
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruy")
|
||||||
|
store.markTeleported("AAAAAA")
|
||||||
|
store.markTeleported("BBBBBB")
|
||||||
|
store.markTeleported("CCCCCC")
|
||||||
|
|
||||||
|
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
|
||||||
|
|
||||||
|
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
|
||||||
|
#expect(store.teleportedGeo == Set(["cccccc"]))
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruz")
|
||||||
|
#expect(store.teleportedGeo.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
|
||||||
|
@MainActor
|
||||||
|
func locationPresenceStoreBoundsGeoNicknames() {
|
||||||
|
let store = LocationPresenceStore(geoNicknameCapacity: 2)
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruy")
|
||||||
|
store.setNickname("alice", for: "AAAAAA")
|
||||||
|
store.setNickname("bob", for: "BBBBBB")
|
||||||
|
store.setNickname("carol", for: "CCCCCC")
|
||||||
|
|
||||||
|
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
|
||||||
|
|
||||||
|
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
|
||||||
|
#expect(store.geoNicknames == ["cccccc": "carol"])
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruz")
|
||||||
|
#expect(store.geoNicknames.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("PeerHandle equality and hashing use the canonical identity only")
|
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||||
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
||||||
|
|||||||
@@ -646,6 +646,25 @@ struct ChatViewModelFormattingTests {
|
|||||||
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func formatMessageAsText_longCashuFallsBackToPlain() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "fmt-long-cashu",
|
||||||
|
sender: "Alice#a1b2",
|
||||||
|
content: longContent,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: PeerID(str: "00000000000000b3")
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func formatMessageHeader_formatsSenderHeader() async {
|
func formatMessageHeader_formatsSenderHeader() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
|
|||||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
// Exactly at threshold DOES trigger (uses >= comparison)
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
#expect(content.hasVeryLongToken(threshold: 50))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
|
||||||
|
|
||||||
|
#expect(content.extractCashuLinks().count == 1)
|
||||||
|
#expect(content.isLongForDisplay())
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
|
||||||
|
let context = MockMessageFormattingContext(nickname: "carol")
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "long-cashu",
|
||||||
|
sender: "alice",
|
||||||
|
content: longContent,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -116,4 +116,87 @@ struct MessageRateLimiterTests {
|
|||||||
#expect(plain)
|
#expect(plain)
|
||||||
#expect(!plainExhausted)
|
#expect(!plainExhausted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Content buckets do not grow when sender is rate limited")
|
||||||
|
func contentBucketsDoNotGrowAfterSenderLimit() {
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 1,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: 10,
|
||||||
|
maxContentBuckets: 10,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
|
||||||
|
var rejected = true
|
||||||
|
for index in 1...100 {
|
||||||
|
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
|
||||||
|
rejected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(first)
|
||||||
|
#expect(rejected)
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == 1)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Bucket maps evict entries at configured caps")
|
||||||
|
func bucketMapsEvictAtConfiguredCaps() {
|
||||||
|
let maxEntries = 3
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 1,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: maxEntries,
|
||||||
|
maxContentBuckets: maxEntries,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
for index in 0..<25 {
|
||||||
|
_ = limiter.allow(
|
||||||
|
senderKey: "sender-\(index)",
|
||||||
|
contentKey: "content-\(index)",
|
||||||
|
now: now.addingTimeInterval(TimeInterval(index))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PoW bypass still creates content buckets under the cap")
|
||||||
|
func powBypassCreatesBoundedContentBuckets() {
|
||||||
|
let maxEntries = 3
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 100,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: maxEntries,
|
||||||
|
maxContentBuckets: maxEntries,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
var allAllowed = true
|
||||||
|
for index in 0..<10 {
|
||||||
|
let allowed = limiter.allow(
|
||||||
|
senderKey: "sender",
|
||||||
|
contentKey: "content-\(index)",
|
||||||
|
powBits: NostrPoW.rateLimitBypassBits,
|
||||||
|
now: now.addingTimeInterval(TimeInterval(index))
|
||||||
|
)
|
||||||
|
if !allowed { allAllowed = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(allAllowed)
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == 0)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,65 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsTooManyTags() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = Array(
|
||||||
|
repeating: ["g", "u4pruyd"],
|
||||||
|
count: TransportConfig.nostrMaxEventTags + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [Array(
|
||||||
|
repeating: "value",
|
||||||
|
count: TransportConfig.nostrMaxEventTagValues + 1
|
||||||
|
)]
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [[
|
||||||
|
"g",
|
||||||
|
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
|
||||||
|
]]
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
|
||||||
|
|
||||||
|
let event = try NostrEvent(from: eventDict)
|
||||||
|
|
||||||
|
#expect(event.tags.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
private static func validInboundEventDict() -> [String: Any] {
|
||||||
|
[
|
||||||
|
"id": String(repeating: "0", count: 64),
|
||||||
|
"pubkey": String(repeating: "1", count: 64),
|
||||||
|
"created_at": 1_234_567,
|
||||||
|
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||||
|
"tags": [["g", "u4pruyd"]],
|
||||||
|
"content": "hello",
|
||||||
|
"sig": String(repeating: "2", count: 128)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
|
|||||||
Reference in New Issue
Block a user