Merge ecd4da139c5072e1fd8c4bb20bff8f3175ae513e into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Vincenzo Palazzo 2026-08-11 09:05:56 +02:00 committed by GitHub
commit 55d653e272
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2151 additions and 2 deletions

View File

@ -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<Int> = [
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 }

View File

@ -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 = [

View File

@ -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␟<pack-coordinate>␟<shortcode>␟<plaintext-sha256>`
/// 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>:<identifier>`.
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>:<identifier>` 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}<coordinate>\u{1F}<shortcode>\u{1F}<sha256>`
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])
)
}
}

View File

@ -0,0 +1,294 @@
//
// StickerCache.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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/<sha256>[.<ext>]`. 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<Data, Swift.Error>] = [:]
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()
}
}

View File

@ -0,0 +1,203 @@
//
// StickerInstallStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Local + relay-synced list of installed Sonar sticker packs (Nostr kind
/// 10031, `a` tags of `30031:<author>:<d>` 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<String>()
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)
}
}

View File

@ -0,0 +1,497 @@
//
// StickerPackService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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:<author-pubkey-hex>:<d-identifier>`.
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<Void, Never>?
/// 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<String> = ["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<String>()
var seenHashes = Set<String>()
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
}
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -0,0 +1,285 @@
//
// StickerCacheTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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 `<hash>.<ext>` 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)
}
}

View File

@ -0,0 +1,111 @@
//
// StickerPackFetchTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
}

View File

@ -0,0 +1,320 @@
//
// StickerPackValidationTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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
))
}
}

147
docs/SONAR-STICKERS.md Normal file
View File

@ -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␟<pack-coordinate>␟<shortcode>␟<plaintext-sha256>
```
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>`; 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:<pubkey>:<identifier>` 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.