Merge 5960b7f343bf98e775ce2f014fd55b959d761884 into 948d6a85b9f254760c475a64f02fe8899e4f3d7f

This commit is contained in:
Vincenzo Palazzo 2026-08-01 10:46:03 +02:00 committed by GitHub
commit 1a25978e59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 437 additions and 0 deletions

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,172 @@
//
// 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() throws {
// 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)"
// #require (not #expect against makeRef): if a regression made parse
// return nil here, `nil == nil` would let the test pass vacuously.
let ref = try #require(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: - Truncated / oversized wire input
@Test func rejectsTruncatedAndOversizedWireRefs() {
let wire = "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)"
// Truncated at a field boundary, mid-hash, and mid-tag: a cut
// packet must never parse as a valid ref.
for cut in [wire.count - 1, wire.count - 32, wire.count / 2, 9, 1] {
#expect(StickerRefCodec.parse(String(wire.prefix(cut))) == nil)
}
// Valid framing with a hash truncated to 63 chars.
#expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(String(sha256.dropLast()))") == nil)
// Oversized fields (64KiB) must be rejected by the length guards
// before any field is trusted, so a bad packet cannot make the
// decoder retain an inflated buffer.
let huge = String(repeating: "a", count: 65_536)
#expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):\(huge)\u{1F}wave\u{1F}\(sha256)") == nil)
#expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}\(huge)\u{1F}\(sha256)") == nil)
#expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(huge)") == nil)
}
// 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)
}
}
}

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

@ -0,0 +1,163 @@
# 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`.
> **Status:** this lands in slices. The wire codec below is merged first as
> dead-on-arrival infrastructure — **nothing in the client parses, routes,
> fetches, or renders sticker references yet.** Parsing, consent-gated
> rendering, and opt-in sync arrive in the follow-up PRs (see the split of
> #1517). Until then, sticker content is ordinary text to every client.
> **DM size constraint:** a maximally long reference (80-char identifier +
> 64-char shortcode) is ~290 bytes, which exceeds the current 255-byte
> private-message content limit (`PrivateMessagePacket`). Senders SHOULD keep
> the encoded reference within 255 bytes for DMs until that limit is raised
> (tracked in #784); oversized references remain valid on public mesh and
> geohash channels.
## 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 180 chars of `[A-Za-z0-9._-]` |
| 3 | shortcode | 164 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` into **at most 5 parts**, preserving empty
subsequences. Beware off-by-one semantics across languages: this is
Swift's `split(separator:maxSplits:)` with `maxSplits: 4` (4 splits → up
to 5 parts), but Rust's `splitn(5, ...)``splitn(4, ...)` yields at
most 4 parts and would reject every valid reference.
2. Accept only if the split yields **exactly 5 parts**, `parts[0]` is empty,
and `parts[1] == "sticker"`.
3. Validate fields 24 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.