From 20c08bd860805a747561534ff02e88e66abf54c4 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 29 Jul 2026 19:23:12 +0200 Subject: [PATCH 1/2] feat: add Sonar sticker reference wire codec (PR A of #1517 split) Pure-Swift codec for the sonar-sticker-pack-v1 wire format (\x1Fsticker\x1F\x1F\x1F), byte-identical to sonar-ffi, plus the wire spec doc and the reserved-not-advertised capability bit 13 note. No behavior change: nothing parses or routes sticker content yet (PRs B/C build on this). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Protocols/PeerCapabilities+Local.swift | 9 ++ bitchat/Protocols/StickerRefCodec.swift | 93 +++++++++++ .../Protocols/StickerRefCodecTests.swift | 148 ++++++++++++++++++ docs/SONAR-STICKERS.md | 147 +++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 bitchat/Protocols/StickerRefCodec.swift create mode 100644 bitchatTests/Protocols/StickerRefCodecTests.swift create mode 100644 docs/SONAR-STICKERS.md diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index d48891d7..cb4c65ba 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -1,6 +1,15 @@ import BitFoundation extension PeerCapabilities { + // NOTE: a `.stickers` bit is RESERVED at bit 13 but intentionally not + // declared or advertised in v1 — sticker refs ride as ordinary message + // content and need no capability negotiation (old clients render the + // literal text; see `docs/SONAR-STICKERS.md`). Bit 11 is claimed by + // #1107 (double ratchet) and bit 12 by #1438 (spray recovery); bit 10 + // stays reserved (`nonDestructiveNoiseReplacement`). Declare the bit + // here — or in BitFoundation on the next bump — when inline-BLE sticker + // delivery (Approach B) ships. + /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. static let localSupported: PeerCapabilities = [ diff --git a/bitchat/Protocols/StickerRefCodec.swift b/bitchat/Protocols/StickerRefCodec.swift new file mode 100644 index 00000000..96402c50 --- /dev/null +++ b/bitchat/Protocols/StickerRefCodec.swift @@ -0,0 +1,93 @@ +import Foundation + +/// A reference to a single sticker inside a Sonar sticker pack, carried as +/// ordinary message content. +/// +/// Interop-identical to sonar-ffi's `mesh_sticker_content` / +/// `mesh_parse_sticker_content`: a ref encodes to +/// `␟sticker␟␟␟` +/// where `␟` is ASCII Unit Separator (0x1F). The full upstream pack spec +/// lives at https://sonarprivacy.xyz/docs#SONAR-STICKERS. +struct StickerRef: Equatable, Sendable { + /// Nostr addressable coordinate of the pack: `30031:<64 lowercase hex pubkey>:`. + let packCoordinate: String + /// Sticker shortcode within the pack: 1-64 chars of `[A-Za-z0-9_]`. + let shortcode: String + /// Lowercase hex SHA-256 of the decrypted sticker plaintext (64 chars). + let plaintextSha256: String + + /// Validated factory. Returns nil unless every field matches the wire + /// shape exactly; encoding an invalid ref is not representable. + init?(packCoordinate: String, shortcode: String, plaintextSha256: String) { + guard StickerRef.isValidCoordinate(packCoordinate), + StickerRef.isValidShortcode(shortcode), + StickerRef.isValidSha256(plaintextSha256) + else { return nil } + self.packCoordinate = packCoordinate + self.shortcode = shortcode + self.plaintextSha256 = plaintextSha256 + } + + /// The wire-format message content for this ref. + var content: String { StickerRefCodec.encode(self) } + + // MARK: - Validation helpers (reused by the pack service) + + /// `30031:<64 lowercase hex>:` where identifier is 1-80 + /// chars of `[A-Za-z0-9._-]`. + static func isValidCoordinate(_ value: String) -> Bool { + let parts = value.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 3, parts[0] == "30031" else { return false } + guard isLowerHex(parts[1], count: 64) else { return false } + let identifier = parts[2] + guard (1...80).contains(identifier.count) else { return false } + return identifier.allSatisfy { byte in + byte.isASCII && (byte.isLetter || byte.isNumber || byte == "." || byte == "_" || byte == "-") + } + } + + /// 1-64 chars of `[A-Za-z0-9_]`. + static func isValidShortcode(_ value: String) -> Bool { + guard (1...64).contains(value.count) else { return false } + return value.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "_") } + } + + /// Exactly 64 lowercase hex chars. + static func isValidSha256(_ value: String) -> Bool { + isLowerHex(Substring(value), count: 64) + } + + private static func isLowerHex(_ value: Substring, count: Int) -> Bool { + guard value.count == count else { return false } + return value.allSatisfy { ($0 >= "0" && $0 <= "9") || ($0 >= "a" && $0 <= "f") } + } +} + +/// Pure-Swift codec for the Sonar sticker-ref wire format, byte-identical to +/// sonar-ffi's `mesh_sticker_content` / `mesh_parse_sticker_content`. +enum StickerRefCodec { + /// ASCII Unit Separator; field delimiter and leading sentinel. + static let separator: Character = "\u{1F}" + + /// `\u{1F}sticker\u{1F}\u{1F}\u{1F}` + static func encode(_ ref: StickerRef) -> String { + "\u{1F}sticker\u{1F}\(ref.packCoordinate)\u{1F}\(ref.shortcode)\u{1F}\(ref.plaintextSha256)" + } + + /// Parses message content into a ref. Returns nil for anything that is + /// not exactly five `\u{1F}`-separated fields with an empty leading + /// field, the literal tag `sticker`, and three validated fields. + /// + /// This parses attacker-controlled mesh content: it must never crash and + /// must never accept a malformed ref, so old clients render the literal + /// text instead of a bogus sticker. + static func parse(_ content: String) -> StickerRef? { + let parts = content.split(separator: separator, maxSplits: 4, omittingEmptySubsequences: false) + guard parts.count == 5, parts[0].isEmpty, parts[1] == "sticker" else { return nil } + return StickerRef( + packCoordinate: String(parts[2]), + shortcode: String(parts[3]), + plaintextSha256: String(parts[4]) + ) + } +} diff --git a/bitchatTests/Protocols/StickerRefCodecTests.swift b/bitchatTests/Protocols/StickerRefCodecTests.swift new file mode 100644 index 00000000..03c8c418 --- /dev/null +++ b/bitchatTests/Protocols/StickerRefCodecTests.swift @@ -0,0 +1,148 @@ +// +// StickerRefCodecTests.swift +// bitchatTests +// +// Tests for the Sonar sticker-ref wire codec: round-trip, sonar-ffi parity +// vectors, strict shape rejection, and adversarial (separator-only / huge / +// emoji) input. The codec parses attacker-controlled mesh content, so +// "never crash" matters as much as "parse correctly". +// + +import Foundation +import Testing +@testable import bitchat + +struct StickerRefCodecTests { + + // MARK: - Builders + + private let pubkey = String(repeating: "ab", count: 32) // 64 lowercase hex + private let sha256 = String(repeating: "deadbeef", count: 8) // 64 lowercase hex + + private func makeRef( + coordinate: String? = nil, + shortcode: String = "wave", + hash: String? = nil + ) -> StickerRef? { + StickerRef( + packCoordinate: coordinate ?? "30031:\(pubkey):my-pack", + shortcode: shortcode, + plaintextSha256: hash ?? sha256 + ) + } + + // MARK: - Round trip + + @Test func encodeParseRoundTrip() { + let ref = makeRef() + #expect(ref != nil) + let parsed = StickerRefCodec.parse(StickerRefCodec.encode(ref!)) + #expect(parsed == ref) + #expect(ref!.content == StickerRefCodec.encode(ref!)) + } + + // MARK: - sonar-ffi parity vectors + + @Test func encodeMatchesSonarFfiBytes() { + // mesh_sticker_content output must be byte-identical. + let ref = makeRef()! + let expected = "\u{1F}sticker\u{1F}30031:\(pubkey):my-pack\u{1F}wave\u{1F}\(sha256)" + #expect(StickerRefCodec.encode(ref) == expected) + #expect(StickerRefCodec.encode(ref).hasPrefix("\u{1F}sticker\u{1F}")) + } + + @Test func parseAcceptsSonarFfiEncodedContent() { + // Hand-built exactly as mesh_parse_sticker_content would see it. + let wire = "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)" + let ref = StickerRefCodec.parse(wire) + #expect(ref == makeRef(coordinate: "30031:\(pubkey):pack")) + } + + // MARK: - Not a sticker at all + + @Test func rejectsNonStickerContent() { + #expect(StickerRefCodec.parse("hello world") == nil) + #expect(StickerRefCodec.parse("") == nil) + #expect(StickerRefCodec.parse("sticker:fake") == nil) + // "sticker" tag without the leading Unit Separator sentinel. + #expect(StickerRefCodec.parse("sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)") == nil) + // Right tag, wrong sentinel position. + #expect(StickerRefCodec.parse("x\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)") == nil) + } + + // MARK: - Coordinate validation + + @Test func rejectsBadCoordinates() { + func ref(with coordinate: String) -> StickerRef? { makeRef(coordinate: coordinate) } + + #expect(ref(with: "30032:\(pubkey):pack") == nil) // wrong kind + #expect(ref(with: "30031:\(pubkey.uppercased()):pack") == nil) // uppercase hex + #expect(ref(with: "30031:\(String(pubkey.dropLast())):pack") == nil) // short pubkey + #expect(ref(with: "30031:\(pubkey)00:pack") == nil) // long pubkey + #expect(ref(with: "30031:\(pubkey):bad id") == nil) // space in identifier + #expect(ref(with: "30031:\(pubkey):bad/id") == nil) // slash in identifier + #expect(ref(with: "30031:\(pubkey):") == nil) // empty identifier + #expect(ref(with: "30031:\(pubkey):\(String(repeating: "a", count: 81))") == nil) // 81-char identifier + #expect(ref(with: "30031:\(pubkey)") == nil) // missing identifier field + #expect(ref(with: ":\(pubkey):pack") == nil) // empty kind + } + + @Test func acceptsCoordinateBoundaryIdentifiers() { + #expect(makeRef(coordinate: "30031:\(pubkey):a") != nil) + #expect(makeRef(coordinate: "30031:\(pubkey):\(String(repeating: "a", count: 80))") != nil) + #expect(makeRef(coordinate: "30031:\(pubkey):A-Z_a.z-09") != nil) + } + + // MARK: - Shortcode validation + + @Test func rejectsBadShortcodes() { + #expect(makeRef(shortcode: "") == nil) + #expect(makeRef(shortcode: String(repeating: "w", count: 65)) == nil) + #expect(makeRef(shortcode: "wavé") == nil) // non-ASCII + #expect(makeRef(shortcode: "so-wave") == nil) // dash not allowed + #expect(makeRef(shortcode: "so wave") == nil) + } + + @Test func acceptsShortcodeBoundaries() { + #expect(makeRef(shortcode: "a") != nil) + #expect(makeRef(shortcode: String(repeating: "w", count: 64)) != nil) + #expect(makeRef(shortcode: "Wave_09") != nil) + } + + // MARK: - SHA-256 validation + + @Test func rejectsBadSha256() { + #expect(makeRef(hash: String(repeating: "a", count: 63)) == nil) + #expect(makeRef(hash: String(repeating: "a", count: 65)) == nil) + #expect(makeRef(hash: sha256.uppercased()) == nil) + #expect(makeRef(hash: String(repeating: "g", count: 64)) == nil) // non-hex + } + + // MARK: - Field count / framing + + @Test func rejectsWrongFieldCounts() { + let base = "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)" + #expect(StickerRefCodec.parse(base + "\u{1F}extra") == nil) // trailing field + #expect(StickerRefCodec.parse(base + "\u{1F}\u{1F}\u{1F}") == nil) // >4 splits + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave") == nil) // missing hash + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}\u{1F}\(sha256)") == nil) // empty shortcode field + } + + // MARK: - Never-crash fuzz-ish + + @Test func pathologicalInputsNeverCrashAndNeverParse() { + let cases = [ + String(repeating: "\u{1F}", count: 5), // all separators + String(repeating: "\u{1F}", count: 4096), + "\u{1F}sticker" + String(repeating: "\u{1F}", count: 100), + String(repeating: "a", count: 100_000), // very long + "\u{1F}sticker\u{1F}🌊🌊🌊\u{1F}🌊\u{1F}🌊", // emoji fields + "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)\u{1F}", + "\u{1F}sticker\u{1F}\u{1F}\u{1F}\u{1F}", + "sticker\u{1F}\u{1F}\u{1F}\u{1F}\u{1F}", + ] + for content in cases { + #expect(StickerRefCodec.parse(content) == nil) + } + } +} diff --git a/docs/SONAR-STICKERS.md b/docs/SONAR-STICKERS.md new file mode 100644 index 00000000..2e842b3b --- /dev/null +++ b/docs/SONAR-STICKERS.md @@ -0,0 +1,147 @@ +# Sonar Stickers Specification + +## Overview + +Sonar stickers let peers send image stickers from shared, content-addressed +sticker packs over bitchat. A sticker is **not** sent as image bytes on the +mesh; instead the sender emits a small *sticker reference* as ordinary +message content, and receiving clients resolve the reference to an image via +Nostr + Blossom. This keeps the BLE mesh payload tiny while allowing +arbitrarily large sticker art. + +The upstream pack specification (pack format, Blossom publication, signing) +lives at **https://sonarprivacy.xyz/docs#SONAR-STICKERS**. This document +specifies only the bitchat wire format and client behavior. The reference +implementation is byte-identical to sonar-ffi's `mesh_sticker_content` / +`mesh_parse_sticker_content`. + +## Wire Format + +### Content Prefix + +A sticker reference is encoded as message **content** (UTF-8 text) with the +following exact layout: + +``` +␟sticker␟␟␟ +``` + +where `␟` is ASCII **Unit Separator (0x1F)**. In Swift: + +```swift +"\u{1F}sticker\u{1F}\(coordinate)\u{1F}\(shortcode)\u{1F}\(sha256)" +``` + +Field order and count are fixed: + +| # | Field | Validation | +|---|--------------------|------------| +| 0 | *(empty sentinel)* | MUST be empty (leading `0x1F`) | +| 1 | tag | MUST be the literal ASCII string `sticker` | +| 2 | pack-coordinate | `30031:<64 lowercase hex>:`; identifier is 1–80 chars of `[A-Za-z0-9._-]` | +| 3 | shortcode | 1–64 chars of `[A-Za-z0-9_]` | +| 4 | plaintext-sha256 | exactly 64 lowercase hex chars (SHA-256 of the decrypted sticker plaintext) | + +### Parsing Rules + +Parsers MUST: + +1. Split the content on `0x1F` with **at most 4 splits**, preserving empty + subsequences. +2. Accept only if the split yields **exactly 5 parts**, `parts[0]` is empty, + and `parts[1] == "sticker"`. +3. Validate fields 2–4 against the table above; any violation MUST cause the + whole parse to fail. +4. Treat a failed parse as ordinary text (see *Old-Client Behavior*). +5. Never crash on malformed input: this content is attacker-controlled on + public channels. + +Senders MUST NOT emit a reference whose fields fail validation. The pack +service SHOULD reuse the same validators (`StickerRef.isValidCoordinate`, +`isValidShortcode`, `isValidSha256`) rather than re-implementing them. + +## Where References May Appear + +Sticker references are always carried as **ordinary message content**: + +- **Private DMs** — inside the existing encrypted Noise envelope, exactly + like a text message. The reference (and thus sticker choice) is never + visible to relays or passive observers. +- **Public mesh channel** — as ordinary broadcast message content. +- **Geohash / Nostr channels** — as the content of the usual geohash chat + event (kind 20000), inside the existing encryption for that channel. + +No new message type, TLV, or Nostr kind is introduced for sending stickers. +v1 does not gate sending on capabilities; receiving clients MUST accept +sticker content regardless (old clients render harmless literal text — see +"Old-Client Behavior" below). + +## Capability Bit + +- **`.stickers` = bit 13** (`1 << 13`) — **reserved, NOT advertised in v1.** + +Bit 10 remains reserved (`nonDestructiveNoiseReplacement`, decodable but +unused), bit 11 is claimed by #1107 (double ratchet), and bit 12 by #1438 +(spray recovery); none of these MUST be reused. The stickers bit is +reserved for a future inline-BLE byte delivery mode and is NOT set in v1 +announce packets: references ride as ordinary message content, so there is +nothing to negotiate. When a peer capability becomes meaningful (e.g. +inline delivery), senders SHOULD prefer sending a ref only to peers +advertising the bit and MAY fall back to a short textual description +(`:shortcode:`) otherwise. + +## Pack Resolution + +Resolution is a client-side cache/fetch concern and never touches the mesh: + +1. **Pack lookup (Nostr, kind 30031).** The pack-coordinate is an + addressable Nostr pointer: kind `30031`, author = the 64-hex pubkey, + `d` tag = the identifier. Clients fetch the pack definition event from + relays and extract the sticker list. +2. **Image fetch (Blossom).** Sticker image bytes are fetched over + **HTTPS only** from Blossom servers, **pinned by SHA-256**: the + downloaded bytes MUST hash to the `plaintext-sha256` from the reference + (and to the hash in the pack entry) before caching or rendering. +3. **Media constraints.** Decoders MUST enforce the MIME allowlist + (`image/webp`, `image/png`, `image/apng`, `image/gif`) and dimensions + ≤ 4096×4096. Packs contain ≤ 200 stickers. +4. **Install list (Nostr, kind 10031).** A user's installed packs are + published as a replaceable kind-`10031` event whose `a` tags are + `30031::` pointers, deduplicated in first-seen + order. Clients SHOULD merge relays' versions keeping the newest event. +5. **Fetch consent (privacy).** Resolving an uninstalled pack on behalf of + an inbound message MUST NOT happen automatically at render time: an + on-render fetch is a read beacon — a per-recipient pack URL tells the + sender exactly when the message was viewed, and DM sticker fetches leak + conversation metadata to relays/Blossom hosts. Uninstalled packs render + a placeholder and fetch only on explicit user consent (e.g. a tap). + Packs the user installed resolve automatically. Failed pack lookups + SHOULD be negative-cached with exponential backoff so a missing/dead + pack is not re-fetched on every render (a repeat beacon). + +See https://sonarprivacy.xyz/docs#SONAR-STICKERS for the full pack and +publication spec. + +## Old-Client Behavior + +Clients that predate this spec (or do not advertise `.stickers`) see the +reference as a plain UTF-8 string. Because Unit Separator is a non-printing +control character, the content renders as roughly +`sticker 30031:…:my-pack wave deadbeef…` — ugly but harmless. Old clients +MUST NOT crash on this content, and this spec adds no bytes outside the +existing content field, so no migration is required. + +## Security Rules + +Receivers and pack services MUST: + +- **Verify before caching.** Never cache or render image bytes whose + SHA-256 does not match the expected `plaintext-sha256`. +- **Untrusted-state rendering.** If the currently resolved pack does not + contain the `(shortcode, hash)` pair from a reference, render an + "untrusted / unknown sticker" placeholder — not whatever image the pack + currently has under that shortcode. +- **HTTPS only.** Never fetch or render non-HTTPS URLs, regardless of what + a pack event claims. +- **Never crash on input.** All parsing of references, pack events, and + image metadata is attacker-controlled; treat every field as hostile. From ecd4da139c5072e1fd8c4bb20bff8f3175ae513e Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 29 Jul 2026 19:24:53 +0200 Subject: [PATCH 2/2] feat: Sonar sticker pack fetch, verified cache, install store (PR B of #1517 split) Co-Authored-By: Claude Opus 4.6 (1M context) --- bitchat/Nostr/NostrProtocol.swift | 43 +- bitchat/Services/StickerCache.swift | 294 +++++++++++ bitchat/Services/StickerInstallStore.swift | 203 +++++++ bitchat/Services/StickerPackService.swift | 497 ++++++++++++++++++ bitchat/Services/TransportConfig.swift | 3 + bitchatTests/Services/StickerCacheTests.swift | 285 ++++++++++ .../Services/StickerPackFetchTests.swift | 111 ++++ .../Services/StickerPackValidationTests.swift | 320 +++++++++++ 8 files changed, 1754 insertions(+), 2 deletions(-) create mode 100644 bitchat/Services/StickerCache.swift create mode 100644 bitchat/Services/StickerInstallStore.swift create mode 100644 bitchat/Services/StickerPackService.swift create mode 100644 bitchatTests/Services/StickerCacheTests.swift create mode 100644 bitchatTests/Services/StickerPackFetchTests.swift create mode 100644 bitchatTests/Services/StickerPackValidationTests.swift diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index bfef85e2..776dc4c7 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -34,6 +34,10 @@ struct NostrProtocol { /// recipient tag (`#x`). Regular (stored) kind so it survives until /// its NIP-40 expiration — the whole point is store-and-forward. case courierDrop = 1401 + /// Sonar sticker-pack addressable events and the replaceable + /// installed-pack list (sonar-sticker-pack-v1). See docs/SONAR-STICKERS.md. + case stickerPack = 30031 + case stickerPackList = 10031 } /// Bound work before Base64-decoding either encrypted layer of an inbound @@ -792,6 +796,28 @@ struct NostrEvent: Codable { self.sig = nil self.id = "" // Will be set during signing } + + /// Construct an event with a raw integer kind (for kinds outside the + /// EventKind enum, e.g. outbound kind-30031/10031 sticker events and unit + /// tests). Does NOT enforce inbound tag limits; the security gate stays on + /// init(from:) for untrusted relay input. + init( + id: String = "", + pubkey: String, + createdAt: Int, + kind: Int, + tags: [[String]], + content: String, + sig: String? = nil + ) { + self.id = id + self.pubkey = pubkey + self.created_at = createdAt + self.kind = kind + self.tags = tags + self.content = content + self.sig = sig + } init(from dict: [String: Any]) throws { guard let pubkey = dict["pubkey"] as? String, @@ -802,7 +828,7 @@ struct NostrEvent: Codable { throw NostrError.invalidEvent } - guard Self.isWithinInboundTagLimits(tags) else { + guard Self.isWithinInboundTagLimits(kind: kind, tags: tags) else { throw NostrError.invalidEvent } @@ -818,7 +844,20 @@ struct NostrEvent: Codable { /// 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 } + isWithinInboundTagLimits(kind: nil, tags: tags) + } + + /// Kind-aware variant: Sonar sticker-pack kinds (30031/10031) may carry up + /// to ~200 sticker tags, so they get nostrMaxStickerPackEventTags. + static func isWithinInboundTagLimits(kind: Int?, tags: [[String]]) -> Bool { + let stickerKinds: Set = [ + NostrProtocol.EventKind.stickerPack.rawValue, + NostrProtocol.EventKind.stickerPackList.rawValue + ] + let maxTags = (kind.map { stickerKinds.contains($0) } ?? false) + ? TransportConfig.nostrMaxStickerPackEventTags + : TransportConfig.nostrMaxEventTags + guard tags.count <= maxTags else { return false } for tag in tags { guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false } diff --git a/bitchat/Services/StickerCache.swift b/bitchat/Services/StickerCache.swift new file mode 100644 index 00000000..eaa45f88 --- /dev/null +++ b/bitchat/Services/StickerCache.swift @@ -0,0 +1,294 @@ +// +// StickerCache.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import CryptoKit + +/// Shared on-disk locations for the Sonar sticker feature. +/// +/// Everything lives under `Application Support/files/stickers/` — the same +/// `files` tree that `BLEIncomingFileStore.panicWipe` removes, so sticker +/// data is covered by panic wipe without any extra wiring. +enum StickerStoragePaths { + static func filesDirectory( + base: URL? = nil, + fileManager: FileManager = .default + ) throws -> URL { + let root = try base ?? fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dir = root.appendingPathComponent("files", isDirectory: true) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + static func stickersDirectory( + base: URL? = nil, + fileManager: FileManager = .default + ) throws -> URL { + let dir = try filesDirectory(base: base, fileManager: fileManager) + .appendingPathComponent("stickers", isDirectory: true) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } +} + +/// Content-addressed disk cache for sticker image bytes. +/// +/// Layout: `Application Support/files/stickers/[.]`. The file name +/// is the lowercase hex SHA-256 of the plaintext (validated as strictly +/// `[0-9a-f]{64}` before any path join, so traversal is impossible), plus an +/// optional extension derived from the declared MIME type. +/// +/// Last-access bookkeeping for LRU eviction lives in a small JSON sidecar +/// (`_index.json`) inside the same directory. Chosen over extended attributes +/// because xattrs do not survive some backup/restore and file-copy paths and +/// are awkward to inspect in tests; a sidecar keeps all cache state in one +/// directory that panic wipe already removes. +/// +/// The negative cache (failed hash → next-retry timestamp, exponential backoff +/// from 30s capped at 30min) is intentionally in-memory only: a restart simply +/// resets backoff, which is acceptable for a cache. +actor StickerCache { + enum Error: Swift.Error, Equatable { + /// Hash string is not strictly `[0-9a-f]{64}` (includes traversal attempts). + case invalidSHA256 + /// SHA-256 of the supplied bytes does not match the claimed hash. + case hashMismatch + /// Sticker bytes exceed the 4 MiB hard cap. + case stickerTooLarge + /// A previous fetch failed recently; backoff has not elapsed yet. + case negativeCacheBackoff(retryAfter: Date) + } + + static let maxStickerBytes = 4 * 1024 * 1024 + static let defaultMaxCacheBytes = 100 * 1024 * 1024 + static let negativeCacheInitialDelay: TimeInterval = 30 + static let negativeCacheMaxDelay: TimeInterval = 30 * 60 + + typealias Downloader = @Sendable (URL) async throws -> Data + + private struct IndexEntry: Codable { + var lastAccess: TimeInterval + var fileExtension: String? + var size: Int + } + + private let stickersDirectory: URL + private let indexURL: URL + private let maxCacheBytes: Int + private let fileManager: FileManager + private let now: @Sendable () -> Date + + // No default value: assigned exactly once in init (a later mutation of a + // defaulted property from the nonisolated init is an error in Swift 6 mode). + private var index: [String: IndexEntry] + private var inFlight: [String: Task] = [:] + private var failureCounts: [String: Int] = [:] + private var negativeCache: [String: Date] = [:] + private var lastIndexPersist: Date = .distantPast + + init( + baseDirectory: URL? = nil, + maxCacheBytes: Int = StickerCache.defaultMaxCacheBytes, + fileManager: FileManager = .default, + now: @escaping @Sendable () -> Date = Date.init + ) { + let dir = (try? StickerStoragePaths.stickersDirectory(base: baseDirectory, fileManager: fileManager)) + ?? fileManager.temporaryDirectory + .appendingPathComponent("bitchat-stickers-\(UUID().uuidString)", isDirectory: true) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + self.stickersDirectory = dir + self.indexURL = dir.appendingPathComponent("_index.json", isDirectory: false) + self.maxCacheBytes = maxCacheBytes + self.fileManager = fileManager + self.now = now + // Load the LRU sidecar and drop entries whose file disappeared + // (e.g. manual cleanup). Done inline: actor inits are nonisolated, so + // isolated helper calls from here are an error in Swift 6 mode. + var initialIndex: [String: IndexEntry] = [:] + if let data = try? Data(contentsOf: self.indexURL), + let decoded = try? JSONDecoder().decode([String: IndexEntry].self, from: data) { + initialIndex = decoded + } + for (hash, entry) in initialIndex + where !fileManager.fileExists(atPath: Self.fileURL(in: dir, forHash: hash, extension: entry.fileExtension).path) { + initialIndex.removeValue(forKey: hash) + } + self.index = initialIndex + } + + // MARK: - Reads + + /// Returns cached bytes for a hash, or nil. Invalid hash shapes return nil + /// (never throw, never touch the disk path builder with untrusted input). + func data(for sha256: String) -> Data? { + guard StickerRef.isValidSha256(sha256) else { return nil } + let url: URL + if let entry = index[sha256] { + url = fileURL(forHash: sha256, extension: entry.fileExtension) + } else { + url = fileURL(forHash: sha256, extension: nil) + } + guard let data = try? Data(contentsOf: url) else { return nil } + index[sha256] = IndexEntry( + lastAccess: now().timeIntervalSince1970, + fileExtension: index[sha256]?.fileExtension, + size: data.count + ) + persistIndex() + return data + } + + /// On-disk location of a cached sticker, for renderers that prefer a file + /// URL (animated webp/gif). Nil when not cached or the hash shape is invalid. + func fileURL(for sha256: String) -> URL? { + guard StickerRef.isValidSha256(sha256) else { return nil } + if let entry = index[sha256] { + let url = fileURL(forHash: sha256, extension: entry.fileExtension) + return fileManager.fileExists(atPath: url.path) ? url : nil + } + let bare = fileURL(forHash: sha256, extension: nil) + return fileManager.fileExists(atPath: bare.path) ? bare : nil + } + + // MARK: - Writes + + /// Verifies the SHA-256 of `data` BEFORE writing anything. Rejects invalid + /// hash shapes (traversal guard) and payloads over 4 MiB. + func store(_ data: Data, sha256: String, mime: String) throws { + guard StickerRef.isValidSha256(sha256) else { throw Error.invalidSHA256 } + guard data.count <= Self.maxStickerBytes else { throw Error.stickerTooLarge } + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + guard digest == sha256 else { throw Error.hashMismatch } + let ext = Self.fileExtension(for: mime) + let url = fileURL(forHash: sha256, extension: ext) + try data.write(to: url, options: .atomic) + index[sha256] = IndexEntry( + lastAccess: now().timeIntervalSince1970, + fileExtension: ext, + size: data.count + ) + // Mutation: persist immediately (not throttled) so a restart within + // the 5s read-throttle window can still discover the file — the read + // path only checks the extension-bearing name via the index. + persistIndex(force: true) + evictIfNeeded(protecting: sha256) + } + + // MARK: - Fetch with coalescing + negative cache + + /// Returns cached bytes when present; otherwise coalesces concurrent calls + /// per hash onto a single in-flight download, stores the result (which + /// re-verifies the hash), and applies exponential-backoff negative caching + /// to failures (30s initial, doubling, capped at 30min). + func fetch( + sha256: String, + url: URL, + mime: String = "", + using downloader: @escaping Downloader + ) async throws -> Data { + guard StickerRef.isValidSha256(sha256) else { throw Error.invalidSHA256 } + if let cached = data(for: sha256) { return cached } + if let retryAfter = negativeCache[sha256], now() < retryAfter { + throw Error.negativeCacheBackoff(retryAfter: retryAfter) + } + if let existing = inFlight[sha256] { + return try await existing.value + } + // The shared in-flight task includes verification + storage, so every + // concurrent caller receives bytes whose hash was checked — a follower + // must never observe the raw, unverified download. + let task = Task { [self, downloader] in + let data = try await downloader(url) + try store(data, sha256: sha256, mime: mime) + return data + } + inFlight[sha256] = task + do { + let data = try await task.value + inFlight.removeValue(forKey: sha256) + negativeCache.removeValue(forKey: sha256) + failureCounts.removeValue(forKey: sha256) + return data + } catch { + inFlight.removeValue(forKey: sha256) + recordFailure(for: sha256) + throw error + } + } + + // MARK: - Wipe + + /// Removes every cached sticker and all bookkeeping. In-flight downloads + /// are left to finish (their `store` re-verifies and re-creates entries). + func removeAll() { + try? fileManager.removeItem(at: stickersDirectory) + try? fileManager.createDirectory(at: stickersDirectory, withIntermediateDirectories: true) + index.removeAll() + negativeCache.removeAll() + failureCounts.removeAll() + lastIndexPersist = .distantPast + } + + // MARK: - Internals + + static func fileExtension(for mime: String) -> String? { + switch mime { + case "image/webp": return "webp" + case "image/png", "image/apng": return "png" // APNG carries a PNG signature/container + case "image/gif": return "gif" + default: return nil + } + } + + private nonisolated static func fileURL(in directory: URL, forHash hash: String, extension ext: String?) -> URL { + let name = ext.map { "\(hash).\($0)" } ?? hash + return directory.appendingPathComponent(name, isDirectory: false) + } + + private nonisolated func fileURL(forHash hash: String, extension ext: String?) -> URL { + Self.fileURL(in: stickersDirectory, forHash: hash, extension: ext) + } + + private func recordFailure(for hash: String) { + let count = (failureCounts[hash] ?? 0) + 1 + failureCounts[hash] = count + let delay = min( + Self.negativeCacheInitialDelay * pow(2.0, Double(count - 1)), + Self.negativeCacheMaxDelay + ) + negativeCache[hash] = now().addingTimeInterval(delay) + } + + private func evictIfNeeded(protecting protectedHash: String? = nil) { + var total = index.values.reduce(0) { $0 + $1.size } + guard total > maxCacheBytes else { return } + // Oldest last-access first. + for (hash, entry) in index.sorted(by: { $0.value.lastAccess < $1.value.lastAccess }) { + guard total > maxCacheBytes else { break } + if hash == protectedHash { continue } + try? fileManager.removeItem(at: fileURL(forHash: hash, extension: entry.fileExtension)) + index.removeValue(forKey: hash) + total -= entry.size + } + persistIndex(force: true) + } + + /// Index writes are throttled on the read path (one disk write per 5s at + /// most) and forced on mutations, so hot render loops don't thrash the disk. + private func persistIndex(force: Bool = false) { + guard force || now().timeIntervalSince(lastIndexPersist) > 5 else { return } + guard let data = try? JSONEncoder().encode(index) else { return } + try? data.write(to: indexURL, options: .atomic) + lastIndexPersist = now() + } +} diff --git a/bitchat/Services/StickerInstallStore.swift b/bitchat/Services/StickerInstallStore.swift new file mode 100644 index 00000000..a8befc0c --- /dev/null +++ b/bitchat/Services/StickerInstallStore.swift @@ -0,0 +1,203 @@ +// +// StickerInstallStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Local + relay-synced list of installed Sonar sticker packs (Nostr kind +/// 10031, `a` tags of `30031::` coordinates). +/// +/// The ordered list is persisted locally as JSON under +/// `Application Support/files/stickers/` so panic wipe covers it. Relay +/// publishing is strictly opt-in: `syncEnabled` defaults to false and +/// `publishInstalledList()` refuses to run while it is false — nothing about +/// the installed list leaves the device until the user turns sync on. +/// +/// Local mutations always succeed locally; a publish failure is logged, never +/// rolled back. +actor StickerInstallStore { + enum StickerInstallError: Swift.Error, Equatable { + /// `publishInstalledList()` was called while `syncEnabled` is false. + case syncDisabled + case identityUnavailable + case invalidCoordinate + } + + static let listEventKind = 10031 + static let syncEnabledDefaultsKey = "stickerInstallStore.syncEnabled" + + /// App-wide shared instance (state is on disk + UserDefaults, but one + /// actor keeps in-memory `installed` coherent across views). + static let shared = StickerInstallStore() + + typealias RelayQuery = @Sendable (NostrFilter) async -> [NostrEvent] + typealias EventPublisher = @Sendable (NostrEvent) async throws -> Void + typealias IdentityProvider = @Sendable () throws -> NostrIdentity + + private let storageURL: URL + private let defaults: UserDefaults + private let identityProvider: IdentityProvider + private let relayQuery: RelayQuery + private let publisher: EventPublisher + private let fileManager: FileManager + private let now: @Sendable () -> Date + + /// Ordered installed pack coordinates, deduplicated in first-seen order. + /// No default value: assigned exactly once in init (Swift 6 init isolation). + private(set) var installed: [String] + + /// Opt-in gate for kind-10031 list sync. Default false. + var syncEnabled: Bool { + get { defaults.object(forKey: Self.syncEnabledDefaultsKey) as? Bool ?? false } + set { defaults.set(newValue, forKey: Self.syncEnabledDefaultsKey) } + } + + init( + baseDirectory: URL? = nil, + defaults: UserDefaults = .standard, + identityProvider: @escaping IdentityProvider = { + guard let identity = try NostrIdentityBridge().getCurrentNostrIdentity() else { + throw StickerInstallError.identityUnavailable + } + return identity + }, + relayQuery: @escaping RelayQuery = { filter in + await StickerRelayQuery.oneShot(filter: filter) + }, + publisher: @escaping EventPublisher = { event in + // nil relay set = built-in default relays (never geo relays). The + // manager's own fail-closed queueing applies while Tor bootstraps. + await MainActor.run { NostrRelayManager.shared.sendEvent(event) } + }, + fileManager: FileManager = .default, + now: @escaping @Sendable () -> Date = Date.init + ) { + let dir = (try? StickerStoragePaths.stickersDirectory(base: baseDirectory, fileManager: fileManager)) + ?? fileManager.temporaryDirectory + .appendingPathComponent("bitchat-stickers-\(UUID().uuidString)", isDirectory: true) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + self.storageURL = dir.appendingPathComponent("installed-packs.json", isDirectory: false) + self.defaults = defaults + self.identityProvider = identityProvider + self.relayQuery = relayQuery + self.publisher = publisher + self.fileManager = fileManager + self.now = now + // Loaded inline: actor inits are nonisolated, so isolated helper calls + // from here are an error in Swift 6 mode. + var initial: [String] = [] + if let data = try? Data(contentsOf: self.storageURL), + let coordinates = try? JSONDecoder().decode([String].self, from: data) { + initial = Self.dedupePreservingOrder( + coordinates.filter { StickerRef.isValidCoordinate($0) } + ) + } + self.installed = initial + } + + // MARK: - Local mutations + + /// Appends a coordinate (first-seen order, no duplicates), persists, and + /// publishes the updated list only when `syncEnabled` is true. + func install(_ coordinate: String) async throws { + guard StickerRef.isValidCoordinate(coordinate) else { + throw StickerInstallError.invalidCoordinate + } + guard !installed.contains(coordinate) else { return } + installed.append(coordinate) + persist() + await publishIfSyncEnabled() + } + + func uninstall(_ coordinate: String) async { + guard installed.contains(coordinate) else { return } + installed.removeAll { $0 == coordinate } + persist() + await publishIfSyncEnabled() + } + + func installedPacks() -> [String] { installed } + + // MARK: - Relay sync (kind 10031) + + /// Fetches the canonical identity's latest kind-10031 event from the + /// default relay set and returns its `a`-tag coordinates (validated, + /// deduplicated in first-seen order). Empty when no event exists. + func fetchInstalledList() async throws -> [String] { + let identity = try identityProvider() + var filter = NostrFilter() + filter.kinds = [Self.listEventKind] + filter.authors = [identity.publicKeyHex] + filter.limit = 5 + let events = await relayQuery(filter) + guard let latest = events + .filter({ $0.kind == Self.listEventKind && $0.pubkey == identity.publicKeyHex }) + .max(by: { $0.created_at < $1.created_at }) + else { return [] } + let coordinates = latest.tags.compactMap { tag -> String? in + guard tag.count >= 2, tag[0] == "a", + StickerRef.isValidCoordinate(tag[1]) + else { return nil } + return tag[1] + } + return Self.dedupePreservingOrder(coordinates) + } + + /// Union of local and relay state, preserving first-seen order (local + /// entries first). Call on launch when sync is enabled. + func mergeInstalledFromNetwork() async throws { + let remote = try await fetchInstalledList() + installed = Self.dedupePreservingOrder(installed + remote) + persist() + } + + /// Publishes the local list as a signed kind-10031 event (empty content, + /// `a` tags) to the default relay set. REFUSES while `syncEnabled` is + /// false — the caller must gate this on the explicit opt-in setting. + func publishInstalledList() async throws { + guard syncEnabled else { throw StickerInstallError.syncDisabled } + let identity = try identityProvider() + let unsigned = try NostrEvent(from: [ + "pubkey": identity.publicKeyHex, + "created_at": Int(now().timeIntervalSince1970), + "kind": Self.listEventKind, + "tags": installed.map { ["a", $0] }, + "content": "" + ]) + let signed = try unsigned.sign(with: identity.schnorrSigningKey()) + try await publisher(signed) + } + + // MARK: - Internals + + private func publishIfSyncEnabled() async { + guard syncEnabled else { return } + do { + try await publishInstalledList() + } catch { + SecureLogger.error( + "⚠️ Failed to publish sticker install list: \(error.localizedDescription)", + category: .session + ) + } + } + + static func dedupePreservingOrder(_ coordinates: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for coordinate in coordinates where seen.insert(coordinate).inserted { + result.append(coordinate) + } + return result + } + + private func persist() { + guard let data = try? JSONEncoder().encode(installed) else { return } + try? data.write(to: storageURL, options: .atomic) + } +} diff --git a/bitchat/Services/StickerPackService.swift b/bitchat/Services/StickerPackService.swift new file mode 100644 index 00000000..f618114b --- /dev/null +++ b/bitchat/Services/StickerPackService.swift @@ -0,0 +1,497 @@ +// +// StickerPackService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation +import ImageIO +import Tor + +// Validation of coordinates, shortcodes, and hashes lives in exactly one +// place: `StickerRef` (bitchat/Protocols/StickerRefCodec.swift), the wire +// codec. Pack parsing round-trips candidate values through the codec's +// validators so the pack layer and the wire layer can never drift apart — +// a pack is only installable when every sticker shortcode it declares can +// actually be encoded into a wire reference. + +// MARK: - Models + +struct StickerDimensions: Codable, Equatable, Sendable { + let width: Int + let height: Int +} + +struct Sticker: Codable, Equatable, Sendable, Identifiable { + let shortcode: String + let url: URL + /// Lowercase hex SHA-256 of the plaintext bytes (also present in `url.path`). + let sha256: String + /// One of image/webp, image/png, image/apng, image/gif. + let mime: String + let dim: StickerDimensions? + let alt: String? + let emoji: String? + + var id: String { shortcode } +} + +struct StickerPack: Codable, Equatable, Sendable, Identifiable { + /// `30031::`. + let coordinate: String + let authorPubkey: String + let identifier: String + let title: String + let description: String? + let coverURL: URL? + let createdAt: Date + let stickers: [Sticker] + + var id: String { coordinate } +} + +// MARK: - One-shot relay queries + +/// One-shot, EOSE-bounded Nostr queries used by the sticker services. +/// +/// Goes through `NostrRelayManager.shared`, so the global network-activation +/// gate and the manager's own Tor fail-closed queueing apply; `relayUrls: nil` +/// targets the built-in default relay set (damus/nos.lol/primal/offchain + +/// user-added), never the geo relays. +enum StickerRelayQuery { + @MainActor + static func oneShot( + filter: NostrFilter, + timeout: TimeInterval = 10 + ) async -> [NostrEvent] { + await withCheckedContinuation { continuation in + let query = StickerOneShotQuery(continuation: continuation) + query.start(filter: filter, timeout: timeout) + } + } +} + +@MainActor +private final class StickerOneShotQuery { + private var events: [NostrEvent] = [] + private var continuation: CheckedContinuation<[NostrEvent], Never>? + private var timeoutTask: Task? + /// Deliberate self-retain. The relay handlers and the timeout task capture + /// `self` weakly, so without this the query would deallocate as soon as + /// the `withCheckedContinuation` closure in `oneShot` returns — and + /// neither EOSE nor the timeout could ever resume the continuation, + /// hanging every uncached pack lookup forever. Broken in `finish`. + private var selfRetain: StickerOneShotQuery? + + init(continuation: CheckedContinuation<[NostrEvent], Never>) { + self.continuation = continuation + } + + func start(filter: NostrFilter, timeout: TimeInterval) { + selfRetain = self + let id = "sticker-oneshot-\(UUID().uuidString)" + NostrRelayManager.shared.subscribe( + filter: filter, + id: id, + relayUrls: nil, + handler: { [weak self] event in + Task { @MainActor in self?.events.append(event) } + }, + onEOSE: { [weak self] in + Task { @MainActor in self?.finish(id: id) } + } + ) + timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + self?.finish(id: id) // no-op if EOSE already finished + } + } + + private func finish(id: String) { + guard let continuation else { return } + self.continuation = nil + timeoutTask?.cancel() + NostrRelayManager.shared.unsubscribe(id: id) + continuation.resume(returning: events) + selfRetain = nil + } +} + +// MARK: - Service + +/// Resolves Sonar sticker packs (Nostr kind 30031, format +/// `sonar-sticker-pack-v1`) and downloads/caches sticker images. +/// +/// Rendering never hits the network synchronously: pack metadata is cached in +/// memory (plus a small JSON sidecar) with a 24h TTL, and image bytes come +/// from `StickerCache` whenever possible. All internet access is Tor-aware and +/// fail-closed (see `makeTorDownloader`). +@MainActor +final class StickerPackService: ObservableObject { + typealias RelayQuery = @Sendable (NostrFilter) async -> [NostrEvent] + typealias Downloader = @Sendable (URL) async throws -> Data + + enum StickerPackError: Swift.Error, Equatable { + case packNotFound + case invalidCoordinate + /// Network activation gate is closed (no permission/favorites/offline). + case networkNotAllowed + /// Tor preference is on but Tor failed to become ready. Fail-closed: + /// the fetch is refused rather than leaked to clearnet. + case torNotReady + /// A recent fetch for this pack came up empty; exponential backoff + /// has not elapsed yet. In-memory only (a restart resets it). + case packFetchBackoff(retryAfter: Date) + case downloadTooLarge + case mimeMismatch + case invalidImageData + } + + nonisolated static let packEventKind = 30031 + nonisolated static let packFormatValue = "sonar-sticker-pack-v1" + nonisolated static let maxStickersPerPack = 200 + nonisolated static let maxImageDimension = 4096 + nonisolated static let metadataTTL: TimeInterval = 24 * 60 * 60 + nonisolated static let allowedMimes: Set = ["image/webp", "image/png", "image/apng", "image/gif"] + + static let shared = StickerPackService() + + /// Pack metadata keyed by coordinate, for synchronous render lookups. + @Published private(set) var packs: [String: StickerPack] = [:] + private var packFetchedAt: [String: Date] = [:] + /// Negative cache for pack fetches: coordinate → do-not-retry-until. + /// In-memory only (a restart simply resets backoff), mirroring + /// `StickerCache`'s failure policy. + private var packFailureCounts: [String: Int] = [:] + private var packNegativeCache: [String: Date] = [:] + + private let cache: StickerCache + private let relayQuery: RelayQuery + private let downloader: Downloader + private let networkActivationAllowed: @Sendable () async -> Bool + private let now: @Sendable () -> Date + private let metadataURL: URL? + + init( + cache: StickerCache = StickerCache(), + relayQuery: @escaping RelayQuery = { filter in + await StickerRelayQuery.oneShot(filter: filter) + }, + downloader: @escaping Downloader = StickerPackService.makeTorDownloader(), + networkActivationAllowed: @escaping @Sendable () async -> Bool = { + await NetworkActivationService.shared.activationAllowed + }, + metadataURL: URL? = try? StickerStoragePaths.stickersDirectory() + .appendingPathComponent("pack-metadata.json", isDirectory: false), + now: @escaping @Sendable () -> Date = Date.init + ) { + self.cache = cache + self.relayQuery = relayQuery + self.downloader = downloader + self.networkActivationAllowed = networkActivationAllowed + self.metadataURL = metadataURL + self.now = now + loadMetadata() + } + + // MARK: - Pack event parsing (pure, spec-validating) + + /// Parses and fully validates a kind-30031 `sonar-sticker-pack-v1` event. + /// Any spec violation rejects the whole pack (returns nil). + nonisolated static func parsePackEvent(_ event: NostrEvent) -> StickerPack? { + guard event.kind == packEventKind else { return nil } + guard StickerRef.isValidSha256(event.pubkey) else { return nil } + guard event.tags.contains(where: { + $0.count >= 2 && $0[0] == "pack_format" && $0[1] == packFormatValue + }) else { return nil } + + guard let dTag = event.tags.first(where: { $0.count >= 2 && $0[0] == "d" }) else { return nil } + let identifier = dTag[1] + let coordinate = "30031:\(event.pubkey):\(identifier)" + // Round-trip through the wire codec: a pack is only valid when its + // coordinate is exactly one a StickerRef can express. + guard StickerRef.isValidCoordinate(coordinate) else { return nil } + + guard let titleTag = event.tags.first(where: { $0.count >= 2 && $0[0] == "title" }), + titleTag[1].utf8.count <= 256, + !titleTag[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + + let description = event.tags.first(where: { $0.count >= 2 && $0[0] == "description" })?[1] + + var coverURL: URL? = nil + if let imageTag = event.tags.first(where: { $0.count >= 2 && $0[0] == "image" }) { + guard let url = validatedHTTPSURL(imageTag[1]) else { return nil } + coverURL = url + } + + let stickerTags = event.tags.filter { $0.count >= 2 && $0[0] == "sticker" } + guard (1...maxStickersPerPack).contains(stickerTags.count) else { return nil } + + var seenShortcodes = Set() + var seenHashes = Set() + var stickers: [Sticker] = [] + stickers.reserveCapacity(stickerTags.count) + + for tag in stickerTags { + // ["sticker", shortcode, url, sha256, mime, dim?, alt?, emoji?] + guard tag.count >= 5 else { return nil } + let shortcode = tag[1] + let hash = tag[3] + let mime = tag[4] + guard StickerRef.isValidShortcode(shortcode) else { return nil } + guard StickerRef.isValidSha256(hash) else { return nil } + guard allowedMimes.contains(mime) else { return nil } + guard let url = validatedStickerURL(tag[2], sha256: hash) else { return nil } + + var dim: StickerDimensions? = nil + if tag.count >= 6, !tag[5].isEmpty { + guard let parsed = parseDimensions(tag[5]) else { return nil } + dim = parsed + } + let alt = tag.count >= 7 ? tag[6] : nil + let emoji = tag.count >= 8 ? tag[7] : nil + + guard seenShortcodes.insert(shortcode).inserted else { return nil } + guard seenHashes.insert(hash).inserted else { return nil } + stickers.append(Sticker( + shortcode: shortcode, url: url, sha256: hash, + mime: mime, dim: dim, alt: alt, emoji: emoji + )) + } + + return StickerPack( + coordinate: coordinate, + authorPubkey: event.pubkey, + identifier: identifier, + title: titleTag[1], + description: description, + coverURL: coverURL, + createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), + stickers: stickers + ) + } + + /// HTTPS-only, no embedded credentials. + nonisolated static func validatedHTTPSURL(_ raw: String) -> URL? { + guard let url = URL(string: raw), + url.scheme?.lowercased() == "https", + url.host != nil, + url.user == nil, url.password == nil + else { return nil } + return url + } + + /// Sticker asset URL: HTTPS-only and the path must contain the lowercase + /// sha256 (binds the URL to the pinned hash). + nonisolated static func validatedStickerURL(_ raw: String, sha256: String) -> URL? { + guard let url = validatedHTTPSURL(raw), + url.path.lowercased().contains(sha256) + else { return nil } + return url + } + + /// `WxH` with both dimensions in 1...4096. + nonisolated static func parseDimensions(_ raw: String) -> StickerDimensions? { + let parts = raw.split(separator: "x", omittingEmptySubsequences: false) + guard parts.count == 2, + let w = Int(parts[0]), let h = Int(parts[1]), + (1...maxImageDimension).contains(w), + (1...maxImageDimension).contains(h) + else { return nil } + return StickerDimensions(width: w, height: h) + } + + // MARK: - Pack fetching + + /// Fetches a pack by author + identifier, preferring the 24h metadata + /// cache. Relay queries are one-shot (EOSE-bounded, ~10s timeout) on the + /// built-in default relay set; the latest `created_at` wins. Misses are + /// negative-cached with exponential backoff (30s → 30min, in-memory) so a + /// missing/dead pack is not re-queried on every render — each retry would + /// be a repeat read beacon to the relays. + func fetchPack(authorPubkeyHex: String, identifier: String) async throws -> StickerPack { + let coordinate = "30031:\(authorPubkeyHex):\(identifier)" + // Round-trip through the wire codec (see parsePackEvent). + guard StickerRef.isValidCoordinate(coordinate) + else { throw StickerPackError.invalidCoordinate } + + if let cached = packs[coordinate], + let fetchedAt = packFetchedAt[coordinate], + now().timeIntervalSince(fetchedAt) < Self.metadataTTL { + return cached + } + + if let retryAfter = packNegativeCache[coordinate], now() < retryAfter { + throw StickerPackError.packFetchBackoff(retryAfter: retryAfter) + } + + // NOTE: NostrFilter.tagFilters is fileprivate, so a server-side "#d" + // filter cannot be expressed from this file. We query by kind+author + // and filter the identifier client-side. A follow-up could add a + // `NostrFilter.stickerPack(author:identifier:)` factory next to the + // other factories in NostrRelayManager.swift. + var filter = NostrFilter() + filter.kinds = [Self.packEventKind] + filter.authors = [authorPubkeyHex] + filter.limit = 20 + + let events = await relayQuery(filter) + let candidates = events + .compactMap(Self.parsePackEvent) + .filter { $0.authorPubkey == authorPubkeyHex && $0.identifier == identifier } + guard let latest = candidates.max(by: { $0.createdAt < $1.createdAt }) else { + recordPackFailure(for: coordinate) + throw StickerPackError.packNotFound + } + + packs[coordinate] = latest + packFetchedAt[coordinate] = now() + packFailureCounts.removeValue(forKey: coordinate) + packNegativeCache.removeValue(forKey: coordinate) + persistMetadata() + return latest + } + + /// In-memory exponential backoff for failed pack fetches (30s initial, + /// doubling, capped at 30min — same policy as `StickerCache`). + private func recordPackFailure(for coordinate: String) { + let count = (packFailureCounts[coordinate] ?? 0) + 1 + packFailureCounts[coordinate] = count + let delay = min( + StickerCache.negativeCacheInitialDelay * pow(2.0, Double(count - 1)), + StickerCache.negativeCacheMaxDelay + ) + packNegativeCache[coordinate] = now().addingTimeInterval(delay) + } + + /// Synchronous cache-only lookup for renderers — never queries the network. + func cachedPack(forCoordinate coordinate: String) -> StickerPack? { + packs[coordinate] + } + + // MARK: - Image fetching + + /// Returns sticker bytes from the disk cache when present; otherwise + /// downloads through the Tor-aware fail-closed pipeline (network-activation + /// gate → streamed 4 MiB cap → MIME sniff vs declared → CGImageSource + /// dimension check) and stores them in `StickerCache`, which re-verifies + /// the sha256 and applies negative-cache backoff to failures. + func imageData(for sticker: Sticker) async throws -> Data { + if let data = await cache.data(for: sticker.sha256) { return data } + return try await cache.fetch( + sha256: sticker.sha256, + url: sticker.url, + mime: sticker.mime + ) { [downloader, networkActivationAllowed] url in + guard await networkActivationAllowed() else { + throw StickerPackError.networkNotAllowed + } + let data = try await downloader(url) + try Self.validateDownloadedImage(data, declaredMime: sticker.mime) + return data + } + } + + /// Pack-edit attack guard: a ref is only trustworthy when the CURRENT pack + /// still contains BOTH the shortcode and the pinned plaintext hash. When + /// this is false the caller must render an "untrusted sticker" placeholder. + nonisolated func validateStickerRef( + packCoordinate: String, + shortcode: String, + plaintextSha256: String, + against pack: StickerPack + ) -> Bool { + guard pack.coordinate == packCoordinate else { return false } + return pack.stickers.contains { + $0.shortcode == shortcode && $0.sha256 == plaintextSha256 + } + } + + // MARK: - Download validation + + /// Size cap, MIME byte-sniff vs declared type (APNG sniffs as PNG — both + /// carry the PNG signature), and CGImageSource dimension check (≤4096²). + nonisolated static func validateDownloadedImage(_ data: Data, declaredMime: String) throws { + guard !data.isEmpty, data.count <= StickerCache.maxStickerBytes else { + throw StickerPackError.downloadTooLarge + } + let sniffOK: Bool + switch declaredMime { + case "image/webp": sniffOK = MimeType.webp.matches(data: data) + case "image/png", "image/apng": sniffOK = MimeType.png.matches(data: data) + case "image/gif": sniffOK = MimeType.gif.matches(data: data) + default: sniffOK = false + } + guard sniffOK else { throw StickerPackError.mimeMismatch } + + let options = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, options), + CGImageSourceGetType(source) != nil, + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int, + width >= 1, height >= 1, + width <= maxImageDimension, height <= maxImageDimension + else { throw StickerPackError.invalidImageData } + } + + /// Default downloader: all traffic through `TorURLSession.shared.session`, + /// fail-closed on the Tor preference (mirrors GeoRelayDirectory): when Tor + /// is wanted but not ready, the fetch is refused — never clearnet. + /// Content-Length is not trusted; bytes are counted as they arrive. + nonisolated static func makeTorDownloader() -> Downloader { + { url in + if NetworkActivationService.persistedTorPreference() { + guard await TorManager.shared.awaitReady() else { + throw StickerPackError.torNotReady + } + } + let session = TorURLSession.shared.session + let (bytes, response) = try await session.bytes(from: url) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) + else { throw URLError(.badServerResponse) } + var data = Data() + for try await byte in bytes { + guard data.count < StickerCache.maxStickerBytes else { + throw StickerPackError.downloadTooLarge + } + data.append(byte) + } + return data + } + } + + // MARK: - Metadata persistence (24h TTL, small JSON sidecar) + + private struct PersistedMetadata: Codable { + var packs: [String: StickerPack] + var fetchedAt: [String: Date] + } + + private func persistMetadata() { + guard let metadataURL, + let data = try? JSONEncoder().encode( + PersistedMetadata(packs: packs, fetchedAt: packFetchedAt) + ) else { return } + try? data.write(to: metadataURL, options: .atomic) + } + + private func loadMetadata() { + guard let metadataURL, + let data = try? Data(contentsOf: metadataURL), + let decoded = try? JSONDecoder().decode(PersistedMetadata.self, from: data) + else { return } + let reference = now() + for (coordinate, fetchedAt) in decoded.fetchedAt + where reference.timeIntervalSince(fetchedAt) < Self.metadataTTL { + guard let pack = decoded.packs[coordinate] else { continue } + packs[coordinate] = pack + packFetchedAt[coordinate] = fetchedAt + } + } +} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 98029094..50e40325 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -98,6 +98,9 @@ enum TransportConfig { // Reject oversized/untrusted relay frames before JSON parse / store. static let nostrMaxInboundMessageBytes: Int = 256 * 1024 static let nostrMaxEventTags: Int = 64 + // Sonar sticker packs can carry up to 200 stickers (1 tag each) plus + // metadata, so kind 30031/10031 events get a higher inbound tag cap. + static let nostrMaxStickerPackEventTags: Int = 220 static let nostrMaxEventTagValues: Int = 16 static let nostrMaxEventTagValueBytes: Int = 1024 // Bounded per-relay inbound frame buffer. Each relay connection owns its diff --git a/bitchatTests/Services/StickerCacheTests.swift b/bitchatTests/Services/StickerCacheTests.swift new file mode 100644 index 00000000..90288aeb --- /dev/null +++ b/bitchatTests/Services/StickerCacheTests.swift @@ -0,0 +1,285 @@ +// +// StickerCacheTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +@Suite("StickerCache Tests") +struct StickerCacheTests { + + // MARK: - Helpers + + private final class MutableClock: @unchecked Sendable { + var current: Date = Date(timeIntervalSince1970: 1_000) + func now() -> Date { current } + } + + private final class Counter: @unchecked Sendable { + var value = 0 + } + + private func makeTempDir() -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("sticker-cache-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private func cleanup(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } + + // MARK: - Tests + + @Test("store → data round-trip returns identical bytes") + func storeThenReadRoundTrip() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + + let bytes = Data("hello sticker".utf8) + let hash = sha256Hex(bytes) + try await cache.store(bytes, sha256: hash, mime: "image/webp") + + let read = await cache.data(for: hash) + #expect(read == bytes) + // Extension is derived from the MIME type. + let fileURL = await cache.fileURL(for: hash) + #expect(fileURL?.lastPathComponent == "\(hash).webp") + } + + @Test("mismatched hash throws before writing and leaves no file") + func mismatchedHashRejectedBeforeWrite() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + + let bytes = Data("actual bytes".utf8) + let wrongHash = String(repeating: "f", count: 64) + + await #expect(throws: StickerCache.Error.hashMismatch) { + try await cache.store(bytes, sha256: wrongHash, mime: "image/png") + } + + let stickersDir = dir.appendingPathComponent("files/stickers", isDirectory: true) + let contents = (try? FileManager.default.contentsOfDirectory(atPath: stickersDir.path)) ?? [] + #expect(contents.isEmpty) + #expect(await cache.data(for: wrongHash) == nil) + } + + @Test("payloads over 4 MiB are rejected") + func oversizedRejected() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + + let big = Data(repeating: 0xAB, count: StickerCache.maxStickerBytes + 1) + let hash = sha256Hex(big) + await #expect(throws: StickerCache.Error.stickerTooLarge) { + try await cache.store(big, sha256: hash, mime: "image/webp") + } + #expect(await cache.data(for: hash) == nil) + } + + @Test("path-traversal and malformed hash strings are rejected") + func traversalRejected() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + let bytes = Data("x".utf8) + + let bad = [ + "../\(String(repeating: "a", count: 61))", + "\(String(repeating: "a", count: 32))/../\(String(repeating: "b", count: 27))", + String(repeating: "A", count: 64), // uppercase is not a valid file name + String(repeating: "g", count: 64), // not hex + String(repeating: "a", count: 63), // too short + "a/a" + String(repeating: "0", count: 61) + ] + for hash in bad { + await #expect(throws: StickerCache.Error.invalidSHA256) { + try await cache.store(bytes, sha256: hash, mime: "image/png") + } + #expect(await cache.data(for: hash) == nil) + } + } + + @Test("removeAll clears cached bytes and negative-cache state") + func removeAllClears() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + + let bytes = Data("wipe me".utf8) + let hash = sha256Hex(bytes) + try await cache.store(bytes, sha256: hash, mime: "image/gif") + #expect(await cache.data(for: hash) != nil) + + await cache.removeAll() + #expect(await cache.data(for: hash) == nil) + #expect(await cache.fileURL(for: hash) == nil) + } + + @Test("LRU eviction removes the oldest last-access entry first") + func lruEvictsOldestAccess() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let clock = MutableClock() + // Cap of 100 bytes: three 40-byte stickers cannot all fit. + let cache = StickerCache(baseDirectory: dir, maxCacheBytes: 100, now: clock.now) + + let aBytes = Data(repeating: 0xA1, count: 40) + let bBytes = Data(repeating: 0xB2, count: 40) + let cBytes = Data(repeating: 0xC3, count: 40) + let a = sha256Hex(aBytes) + let b = sha256Hex(bBytes) + let c = sha256Hex(cBytes) + + clock.current = Date(timeIntervalSince1970: 1) + try await cache.store(aBytes, sha256: a, mime: "image/png") + clock.current = Date(timeIntervalSince1970: 2) + try await cache.store(bBytes, sha256: b, mime: "image/png") + // Touch A so B becomes the oldest last-access entry. + clock.current = Date(timeIntervalSince1970: 3) + _ = await cache.data(for: a) + clock.current = Date(timeIntervalSince1970: 4) + try await cache.store(cBytes, sha256: c, mime: "image/png") + + #expect(await cache.data(for: b) == nil) // evicted + #expect(await cache.data(for: a) != nil) + #expect(await cache.data(for: c) != nil) + } + + @Test("negative cache blocks immediate refetch after a failure") + func negativeCacheBackoffBlocksRefetch() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + + let hash = String(repeating: "1", count: 64) + let url = URL(string: "https://cdn.example.com/stickers/\(hash).webp")! + let calls = Counter() + + await #expect(throws: URLError.self) { + _ = try await cache.fetch(sha256: hash, url: url) { _ in + calls.value += 1 + throw URLError(.notConnectedToInternet) + } + } + #expect(calls.value == 1) + + // Immediate retry must short-circuit without invoking the downloader. + do { + _ = try await cache.fetch(sha256: hash, url: url) { _ in + calls.value += 1 + return Data() + } + Issue.record("expected negativeCacheBackoff error") + } catch let StickerCache.Error.negativeCacheBackoff(retryAfter) { + #expect(retryAfter > Date()) + } + #expect(calls.value == 1) + } + + @Test("concurrent fetches for the same hash coalesce onto one download") + func fetchCoalescesInFlight() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + + let bytes = Data("coalesced".utf8) + let hash = sha256Hex(bytes) + let url = URL(string: "https://cdn.example.com/stickers/\(hash).webp")! + let calls = Counter() + + async let first = cache.fetch(sha256: hash, url: url, mime: "image/webp") { _ in + calls.value += 1 + try? await Task.sleep(nanoseconds: 100_000_000) + return bytes + } + async let second = cache.fetch(sha256: hash, url: url, mime: "image/webp") { _ in + calls.value += 1 + try? await Task.sleep(nanoseconds: 100_000_000) + return bytes + } + + let results = try await [first, second] + #expect(results == [bytes, bytes]) + #expect(calls.value == 1) + // And the result was persisted. + #expect(await cache.data(for: hash) == bytes) + } + + @Test("concurrent followers never receive unverified bytes: a hash mismatch fails every waiter") + func concurrentFollowersObserveValidationFailure() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let cache = StickerCache(baseDirectory: dir) + + let pinned = String(repeating: "1", count: 64) + let url = URL(string: "https://cdn.example.com/stickers/\(pinned).webp")! + let calls = Counter() + + let downloader: StickerCache.Downloader = { _ in + calls.value += 1 + try? await Task.sleep(nanoseconds: 100_000_000) + return Data("tampered".utf8) // does not hash to `pinned` + } + + async let first = cache.fetch(sha256: pinned, url: url, mime: "image/webp", using: downloader) + async let second = cache.fetch(sha256: pinned, url: url, mime: "image/webp", using: downloader) + + // Verification+storage happens INSIDE the shared in-flight task, so + // the follower cannot render the raw, unverified payload: it observes + // the same hashMismatch as the caller that initiated the download. + do { + _ = try await first + Issue.record("expected hashMismatch for the initiating fetch") + } catch StickerCache.Error.hashMismatch {} catch { + Issue.record("expected hashMismatch, got \(error)") + } + do { + _ = try await second + Issue.record("expected hashMismatch for the coalesced follower") + } catch StickerCache.Error.hashMismatch {} catch { + Issue.record("expected hashMismatch, got \(error)") + } + #expect(calls.value == 1) + #expect(await cache.data(for: pinned) == nil) + } + + @Test("store persists the index immediately: a restart inside the throttle window still discovers files") + func storePersistsIndexWithinThrottleWindow() async throws { + let dir = makeTempDir() + defer { cleanup(dir) } + let clock = MutableClock() + let cache = StickerCache(baseDirectory: dir, now: clock.now) + + let bytesA = Data("alpha".utf8) + let hashA = sha256Hex(bytesA) + try await cache.store(bytesA, sha256: hashA, mime: "image/webp") + // Second store lands inside the 5s read-path throttle window. + clock.current = clock.current.addingTimeInterval(1) + let bytesB = Data("bravo".utf8) + let hashB = sha256Hex(bytesB) + try await cache.store(bytesB, sha256: hashB, mime: "image/webp") + + // Simulated restart: a fresh cache over the same directory must + // discover BOTH extension-bearing files through the persisted index — + // the read path only finds `.` via an index entry. + let restarted = StickerCache(baseDirectory: dir, now: clock.now) + #expect(await restarted.data(for: hashA) == bytesA) + #expect(await restarted.data(for: hashB) == bytesB) + } +} diff --git a/bitchatTests/Services/StickerPackFetchTests.swift b/bitchatTests/Services/StickerPackFetchTests.swift new file mode 100644 index 00000000..0a06d40d --- /dev/null +++ b/bitchatTests/Services/StickerPackFetchTests.swift @@ -0,0 +1,111 @@ +// +// StickerPackFetchTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +/// Fetch-path tests for `StickerPackService` with injected relay query, +/// downloader, and clock — no relay, Tor, or network access. +@Suite("Sticker Pack Fetch Tests") +struct StickerPackFetchTests { + + private final class Counter: @unchecked Sendable { var value = 0 } + private final class MutableClock: @unchecked Sendable { + var current = Date(timeIntervalSince1970: 1_700_000_000) + func now() -> Date { current } + } + + private let pubkey = String(repeating: "a", count: 64) + + @MainActor + private func makeService( + clock: MutableClock, + relayQuery: @escaping StickerPackService.RelayQuery + ) -> StickerPackService { + StickerPackService( + cache: StickerCache( + baseDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent("sticker-pack-fetch-tests-\(UUID().uuidString)", isDirectory: true) + ), + relayQuery: relayQuery, + downloader: { _ in throw URLError(.notConnectedToInternet) }, + networkActivationAllowed: { true }, + metadataURL: nil, + now: clock.now + ) + } + + @MainActor + @Test("missing pack is negative-cached: repeats back off instead of re-querying relays") + func missingPackIsNegativeCached() async { + let clock = MutableClock() + let calls = Counter() + let service = makeService(clock: clock) { _ in + calls.value += 1 + return [] + } + + await #expect(throws: StickerPackService.StickerPackError.packNotFound) { + try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope") + } + #expect(calls.value == 1) + + // Immediate retry short-circuits into backoff — no second relay query. + // (Every retry would be a repeat read beacon to the relay set.) + do { + _ = try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope") + Issue.record("expected packFetchBackoff") + } catch let StickerPackService.StickerPackError.packFetchBackoff(retryAfter) { + #expect(retryAfter > clock.current) + } catch { + Issue.record("expected packFetchBackoff, got \(error)") + } + #expect(calls.value == 1) + + // After the initial 30s backoff elapses the relay is queried again, + // and the repeated miss backs off further. + clock.current = clock.current.addingTimeInterval(31) + await #expect(throws: StickerPackService.StickerPackError.packNotFound) { + try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope") + } + #expect(calls.value == 2) + + // Third failure doubled the backoff: +45s is still inside the 60s window. + clock.current = clock.current.addingTimeInterval(45) + do { + _ = try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope") + Issue.record("expected packFetchBackoff") + } catch let StickerPackService.StickerPackError.packFetchBackoff(retryAfter) { + #expect(retryAfter > clock.current) + } catch { + Issue.record("expected packFetchBackoff, got \(error)") + } + #expect(calls.value == 2) + } + + @MainActor + @Test("invalid coordinates are rejected before any relay query") + func invalidCoordinateRejectedBeforeQuery() async { + let clock = MutableClock() + let calls = Counter() + let service = makeService(clock: clock) { _ in + calls.value += 1 + return [] + } + + await #expect(throws: StickerPackService.StickerPackError.invalidCoordinate) { + try await service.fetchPack(authorPubkeyHex: "not-hex", identifier: "nope") + } + // A coordinate the wire codec could never express is refused up front. + await #expect(throws: StickerPackService.StickerPackError.invalidCoordinate) { + try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "has spaces") + } + #expect(calls.value == 0) + } +} diff --git a/bitchatTests/Services/StickerPackValidationTests.swift b/bitchatTests/Services/StickerPackValidationTests.swift new file mode 100644 index 00000000..ae505cd1 --- /dev/null +++ b/bitchatTests/Services/StickerPackValidationTests.swift @@ -0,0 +1,320 @@ +// +// StickerPackValidationTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +/// Pure validation tests for `StickerPackService.parsePackEvent` — no relay, +/// Tor, or network access; `NostrEvent` fixtures are built in code. +@Suite("Sticker Pack Validation Tests") +struct StickerPackValidationTests { + + private let pubkey = String(repeating: "a", count: 64) + private let hash1 = String(repeating: "1", count: 64) + private let hash2 = String(repeating: "2", count: 64) + + // MARK: - Fixture builders + + private func makeEvent( + kind: Int = 30031, + pubkey: String? = nil, + createdAt: Int = 1_700_000_000, + tags: [[String]] + ) -> NostrEvent { + // Raw-kind init (internal, @testable): bypasses the inbound relay + // tag-count cap so >64-tag packs can be exercised for unit tests. + NostrEvent( + pubkey: pubkey ?? self.pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: "" + ) + } + + private func stickerTag( + shortcode: String = "party_blob", + hash: String? = nil, + mime: String = "image/webp", + dim: String? = "128x128", + url: String? = nil + ) -> [String] { + let h = hash ?? hash1 + var tag = ["sticker", shortcode, url ?? "https://cdn.example.com/stickers/\(h).webp", h, mime] + if let dim { tag.append(dim) } + return tag + } + + private func validTags(stickers: [[String]]? = nil) -> [[String]] { + [ + ["d", "cool-pack"], + ["title", "Cool Pack"], + ["pack_format", "sonar-sticker-pack-v1"] + ] + (stickers ?? [stickerTag()]) + } + + // MARK: - Valid pack + + @Test("spec-compliant pack event parses with all fields") + func validPackParses() { + let event = makeEvent(tags: [ + ["d", "cool-pack"], + ["title", "Cool Pack"], + ["description", "A very cool pack"], + ["image", "https://cdn.example.com/cover.png"], + ["pack_format", "sonar-sticker-pack-v1"], + ["sticker", "party_blob", "https://cdn.example.com/stickers/\(hash1).webp", + hash1, "image/webp", "128x128", "Party blob", "🎉"] + ]) + + let pack = StickerPackService.parsePackEvent(event) + #expect(pack != nil) + #expect(pack?.coordinate == "30031:\(pubkey):cool-pack") + #expect(pack?.authorPubkey == pubkey) + #expect(pack?.identifier == "cool-pack") + #expect(pack?.title == "Cool Pack") + #expect(pack?.description == "A very cool pack") + #expect(pack?.coverURL?.absoluteString == "https://cdn.example.com/cover.png") + #expect(pack?.stickers.count == 1) + let sticker = pack?.stickers.first + #expect(sticker?.shortcode == "party_blob") + #expect(sticker?.sha256 == hash1) + #expect(sticker?.mime == "image/webp") + #expect(sticker?.dim == StickerDimensions(width: 128, height: 128)) + #expect(sticker?.alt == "Party blob") + #expect(sticker?.emoji == "🎉") + #expect(pack?.createdAt == Date(timeIntervalSince1970: 1_700_000_000)) + } + + @Test("optional dim/alt/emoji may be absent") + func optionalFieldsMayBeAbsent() { + let event = makeEvent(tags: validTags(stickers: [ + ["sticker", "blob", "https://cdn.example.com/\(hash1).png", hash1, "image/png"] + ])) + let pack = StickerPackService.parsePackEvent(event) + #expect(pack?.stickers.first?.dim == nil) + #expect(pack?.stickers.first?.alt == nil) + #expect(pack?.stickers.first?.emoji == nil) + } + + // MARK: - Envelope rules + + @Test("wrong kind is rejected", arguments: [30032, 1, 10031, 30311]) + func wrongKindRejected(kind: Int) { + #expect(StickerPackService.parsePackEvent(makeEvent(kind: kind, tags: validTags())) == nil) + } + + @Test("missing pack_format tag is rejected") + func missingPackFormatRejected() { + let tags = validTags().filter { $0.first != "pack_format" } + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("wrong pack_format value is rejected") + func wrongPackFormatRejected() { + let tags = validTags().map { $0.first == "pack_format" ? ["pack_format", "other-v1"] : $0 } + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("missing or invalid d tag is rejected", arguments: [nil, "", "has spaces", String(repeating: "x", count: 300)]) + func invalidDTagRejected(identifier: String?) { + var tags = validTags().filter { $0.first != "d" } + if let identifier { tags.append(["d", identifier]) } + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("missing or blank title is rejected", arguments: [nil, "", " "]) + func invalidTitleRejected(title: String?) { + var tags = validTags().filter { $0.first != "title" } + if let title { tags.append(["title", title]) } + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("malformed pubkey is rejected") + func invalidPubkeyRejected() { + #expect(StickerPackService.parsePackEvent(makeEvent(pubkey: "nothex", tags: validTags())) == nil) + #expect(StickerPackService.parsePackEvent( + makeEvent(pubkey: String(repeating: "A", count: 64), tags: validTags()) + ) == nil) + } + + // MARK: - Sticker tag rules + + @Test("pack with zero stickers is rejected") + func noStickersRejected() { + #expect(StickerPackService.parsePackEvent(makeEvent(tags: validTags(stickers: []))) == nil) + } + + @Test("non-HTTPS sticker URL is rejected", arguments: [ + "http://cdn.example.com/x.webp", + "ftp://cdn.example.com/x.webp", + "not-a-url", + "https://user:pass@cdn.example.com/x.webp" + ]) + func nonHTTPSRejected(url: String) { + let tags = validTags(stickers: [stickerTag(url: url)]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("sticker URL whose path lacks the sha256 is rejected") + func urlMissingHashRejected() { + let tags = validTags(stickers: [ + stickerTag(url: "https://cdn.example.com/stickers/other.webp") + ]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("disallowed MIME is rejected", arguments: ["image/jpeg", "image/svg+xml", "video/mp4", "application/octet-stream"]) + func badMimeRejected(mime: String) { + let tags = validTags(stickers: [stickerTag(mime: mime)]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("all allowed MIMEs parse", arguments: ["image/webp", "image/png", "image/apng", "image/gif"]) + func allowedMimesParse(mime: String) { + let tags = validTags(stickers: [stickerTag(mime: mime)]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags))?.stickers.first?.mime == mime) + } + + @Test("malformed sha256 is rejected", arguments: [ + "xyz", String(repeating: "1", count: 63), String(repeating: "F", count: 64) + ]) + func badSHA256Rejected(hash: String) { + let tags = validTags(stickers: [ + ["sticker", "blob", "https://cdn.example.com/\(hash).webp", hash, "image/webp", "10x10"] + ]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + // Shortcode rules come from the wire codec (`StickerRef.isValidShortcode`): + // `[A-Za-z0-9_]{1,64}` — uppercase is valid, dash is not. + @Test("invalid shortcode is rejected", arguments: ["", "has space", "emoji🎉", "so-wave", String(repeating: "a", count: 65)]) + func badShortcodeRejected(shortcode: String) { + let tags = validTags(stickers: [stickerTag(shortcode: shortcode)]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("codec-valid shortcode parses (pack and wire rules cannot drift)") + func codecValidShortcodeParses() { + let tags = validTags(stickers: [stickerTag(shortcode: "Wave_09")]) + let pack = StickerPackService.parsePackEvent(makeEvent(tags: tags)) + #expect(pack?.stickers.first?.shortcode == "Wave_09") + // …and the same shortcode is encodable into a wire reference. + #expect(StickerRef.isValidShortcode("Wave_09")) + } + + @Test("duplicate shortcodes are rejected") + func duplicateShortcodeRejected() { + let tags = validTags(stickers: [ + stickerTag(shortcode: "dup", hash: hash1), + stickerTag(shortcode: "dup", hash: hash2) + ]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("duplicate hashes are rejected") + func duplicateHashRejected() { + let tags = validTags(stickers: [ + stickerTag(shortcode: "one", hash: hash1), + stickerTag(shortcode: "two", hash: hash1) + ]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("packs with more than 200 stickers are rejected") + func tooManyStickersRejected() { + let stickers = (0..<201).map { i -> [String] in + let hash = String(format: "%064d", i + 1) // decimal digits are valid lowercase hex + return ["sticker", "s\(i)", "https://cdn.example.com/\(hash).webp", hash, "image/webp", "10x10"] + } + #expect(StickerPackService.parsePackEvent(makeEvent(tags: validTags(stickers: stickers))) == nil) + } + + @Test("200 stickers exactly parses") + func exactlyMaxStickersParses() { + let stickers = (0..<200).map { i -> [String] in + let hash = String(format: "%064d", i + 1) + return ["sticker", "s\(i)", "https://cdn.example.com/\(hash).webp", hash, "image/webp", "10x10"] + } + let pack = StickerPackService.parsePackEvent(makeEvent(tags: validTags(stickers: stickers))) + #expect(pack?.stickers.count == 200) + } + + @Test("invalid dim is rejected", arguments: ["0x10", "10x0", "5000x10", "10x5000", "abc", "10x20x30", "-5x10", "10X20", "10 x20"]) + func badDimRejected(dim: String) { + let tags = validTags(stickers: [stickerTag(dim: dim)]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + @Test("boundary dims parse", arguments: ["1x1", "4096x4096", "1x4096"]) + func boundaryDimsParse(dim: String) { + let tags = validTags(stickers: [stickerTag(dim: dim)]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags))?.stickers.first?.dim != nil) + } + + @Test("truncated sticker tag is rejected") + func truncatedStickerTagRejected() { + let tags = validTags(stickers: [ + ["sticker", "blob", "https://cdn.example.com/\(hash1).webp", hash1] // missing mime + ]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + // MARK: - Cover image + + @Test("non-HTTPS cover image is rejected") + func nonHTTPSCoverRejected() { + var tags = validTags() + tags.append(["image", "http://cdn.example.com/cover.png"]) + #expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil) + } + + // MARK: - Ref validation (pack-edit attack) + + @Test("validateStickerRef requires both shortcode and pinned hash in the current pack") + @MainActor + func validateStickerRefAgainstPack() { + let event = makeEvent(tags: validTags(stickers: [ + stickerTag(shortcode: "party_blob", hash: hash1) + ])) + guard let pack = StickerPackService.parsePackEvent(event) else { + Issue.record("fixture pack failed to parse") + return + } + let service = StickerPackService( + cache: StickerCache(baseDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent("sticker-svc-tests-\(UUID().uuidString)", isDirectory: true)), + relayQuery: { _ in [] }, + downloader: { _ in throw URLError(.networkConnectionLost) }, + networkActivationAllowed: { false }, + metadataURL: nil + ) + + // Happy path: coordinate + shortcode + pinned hash all match. + #expect(service.validateStickerRef( + packCoordinate: pack.coordinate, shortcode: "party_blob", + plaintextSha256: hash1, against: pack + )) + // Pack edited: shortcode still present but hash changed → untrusted. + #expect(!service.validateStickerRef( + packCoordinate: pack.coordinate, shortcode: "party_blob", + plaintextSha256: hash2, against: pack + )) + // Shortcode removed from the pack → untrusted. + #expect(!service.validateStickerRef( + packCoordinate: pack.coordinate, shortcode: "gone", + plaintextSha256: hash1, against: pack + )) + // Different pack entirely → untrusted. + #expect(!service.validateStickerRef( + packCoordinate: "30031:\(String(repeating: "b", count: 64)):cool-pack", + shortcode: "party_blob", plaintextSha256: hash1, against: pack + )) + } +}