mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Merge 5960b7f343bf98e775ce2f014fd55b959d761884 into 948d6a85b9f254760c475a64f02fe8899e4f3d7f
This commit is contained in:
commit
1a25978e59
@ -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 = [
|
||||
|
||||
93
bitchat/Protocols/StickerRefCodec.swift
Normal file
93
bitchat/Protocols/StickerRefCodec.swift
Normal 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])
|
||||
)
|
||||
}
|
||||
}
|
||||
172
bitchatTests/Protocols/StickerRefCodecTests.swift
Normal file
172
bitchatTests/Protocols/StickerRefCodecTests.swift
Normal 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
163
docs/SONAR-STICKERS.md
Normal 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 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` 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 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.
|
||||
Loading…
x
Reference in New Issue
Block a user