eleven / messenger Public

native: render sealed branding — members decrypt the name and photo (#724 follow-up) (#753)

* native: render sealed branding — members decrypt the name and photo

The #731 follow-up: natives degraded a sealed instance to unnamed +
photoless. Members hold everything needed to do better — the instance-key
copy of the branding key rides /api/group member read — so LChatCore now
carries the whole pipeline (SealedBranding.swift): CommonCrypto
PBKDF2-SHA256 600k -> AES-GCM key (pinned against the SAME vector as
test/crypto.test.mjs, so web and Swift can never drift), the blob shapes,
and a resolver that bootstraps B via ECIES-unwrapping the instance key and
Keychain-caches it per host (stale cache falls back and repairs, mirroring
the web bootstrapBranding).

Wired: ThreadStore.instanceName (both platforms) and the iOS switcher
server photos (decrypted via serverAvatarSealed). The Mac rail legacy
SavedChat model holds no member private key, so it keeps the host-name
fallback. Pre-key surfaces (add-chat) stay generic by design — serverInfo
now reports "sealed" so key-holding callers pick the decrypting path.

No changelog entry — natives announce at release mint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: latch sealed-branding resolution — never per socket frame

Review findings, fixed:
- ThreadStore re-ran the resolver (authed GET + 600k-iteration PBKDF2) on
  EVERY refresh, i.e. every incoming message: groupName() returns "" for a
  sealed instance each pass, so the emptiness guard never latched. The
  resolved name now lives in a stored latch (one attempt per store
  lifetime); the fetched "" never clobbers a resolved name.
- iOS switcher: photo-less sealed instances re-paid the PBKDF2 on every
  task refire (the image cache never fills) — per-launch attempted set,
  cleared on eraseAll.
- Keychain cache keyed by full origin (two localhost dev instances on
  different ports no longer ping-pong each other's B), and evicted in
  AppState.remove — B opens the publicly-served avatar ciphertext, so it
  must not outlive membership on this device.

Deliberate scope (stated, per CLAUDE.md): iOS-first. The Mac rail still
renders sealed spaces as host-initial — its legacy SavedChat model carries
no member private key to unwrap B with; it follows when the rail moves to
SavedInstance. Mac native surfaces reading ThreadStore.instanceName get the
name for free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 46530a820da479c03bc9358e6a3b9b56dafa952f parent d31bbd9
5 files changed, +245 −8
  • ios/LChat/LChatApp.swift +21 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +9 −7
  • ios/LChatCore/Sources/LChatCore/SealedBranding.swift +138 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +23 −1
  • ios/LChatCore/Tests/LChatCoreTests/SealedBrandingTests.swift +54 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 64cdb83..df692f8 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -32,6 +32,9 @@ final class AppState: ObservableObject {
// branding served in the clear at /api/server-avatar (not an E2E blob), so it's
// fetched directly here rather than through BlobImageLoader.
@Published var serverAvatars: [String: UIImage] = [:]
+ // Sealed-avatar latch: instance ids whose sealed photo we already tried to
+ // resolve this launch (the resolver is a 600k-iteration PBKDF2 — once).
+ private var sealedAvatarAttempted: Set<String> = []
init() { instances = InstancesStore.load() }
@@ -41,6 +44,7 @@ final class AppState: ObservableObject {
func eraseAll() {
instances = []
serverAvatars = [:]
+ sealedAvatarAttempted = []
}
/// Fetch each server's photo for the switcher. /api/group is public and reports
@@ -58,6 +62,20 @@ final class AppState: ObservableObject {
let image = UIImage(data: data) {
serverAvatars[inst.id] = image
}
+ } else if info.sealed {
+ // Sealed branding: the photo (if any) is ciphertext — we're a
+ // member with keys, so decrypt it (SealedBranding.swift). One
+ // attempt per launch per instance: the resolver costs a 600k
+ // PBKDF2, and a photo-less sealed instance would otherwise
+ // re-pay it on every task refire (the image cache never fills).
+ if serverAvatars[inst.id] == nil, !sealedAvatarAttempted.contains(inst.id) {
+ sealedAvatarAttempted.insert(inst.id)
+ if let opened = await SealedBrandingResolver.open(api: api, userPrivPkcs8B64: inst.userPriv),
+ let data = await api.serverAvatarSealed(key: opened.key),
+ let image = UIImage(data: data) {
+ serverAvatars[inst.id] = image
+ }
+ }
} else if serverAvatars[inst.id] != nil {
serverAvatars[inst.id] = nil // photo removed on the server — drop back to initials
}
@@ -83,6 +101,9 @@ final class AppState: ObservableObject {
instances.removeAll { $0.id == inst.id }
InstancesStore.save(instances)
ThreadListSnapshot.clear(origin: inst.origin) // no keys linger for a space we left
+ // The cached branding key goes too — it opens the (publicly served)
+ // sealed avatar ciphertext, so it must not outlive membership here.
+ if let base = inst.baseURL { SealedBrandingResolver.clearCache(origin: base.absoluteString) }
// Sync never deletes from the vault — the removed space just stops being
// device-ordered and tails as a vault-only entry (see HomeSync).
HomeSync.pushSoon(instances: instances)
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index adc8264..dbd62d8 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -7,7 +7,7 @@ import Foundation
public struct LChatAPI {
public let baseURL: URL
public let token: String
- private let session = URLSession(configuration: .default)
+ let session = URLSession(configuration: .default) // internal: SealedBranding.swift shares it
public init(baseURL: URL, token: String) {
self.baseURL = baseURL
@@ -369,14 +369,16 @@ public struct LChatAPI {
/// Public per-instance branding: the display name ("" when never named —
/// see groupName) plus whether a server photo is set. Both are public
- /// (shown during add-a-chat, before any key exists).
- public func serverInfo() async -> (name: String, hasAvatar: Bool) {
+ /// (shown during add-a-chat, before any key exists). `sealed` tells a
+ /// key-holding caller to take the decrypting path instead
+ /// (SealedBranding.swift); keyless callers just render the fallbacks.
+ public func serverInfo() async -> (name: String, hasAvatar: Bool, sealed: Bool) {
struct Resp: Codable { let name: String?; let name_set: Bool?; let avatar: Bool?; let sealed: Bool? }
let r = try? await getJSON("/api/group", Resp.self, authed: false)
// Sealed: no readable name, and the photo endpoint serves ciphertext —
- // render as unnamed and photoless until sealed-branding display lands.
- if r?.sealed == true { return ("", false) }
- return (Self.chosenName(r?.name, nameSet: r?.name_set), r?.avatar ?? false)
+ // report unnamed + photoless so keyless surfaces degrade gracefully.
+ if r?.sealed == true { return ("", false, true) }
+ return (Self.chosenName(r?.name, nameSet: r?.name_set), r?.avatar ?? false, false)
}
static func chosenName(_ name: String?, nameSet: Bool?) -> String {
@@ -420,7 +422,7 @@ public struct LChatAPI {
// MARK: Plumbing
- private func getJSON<T: Decodable>(
+ func getJSON<T: Decodable>(
_ path: String, _ type: T.Type, query: [URLQueryItem]? = nil, authed: Bool = true
) async throws -> T {
var comps = URLComponents(url: baseURL.appending(path: path), resolvingAgainstBaseURL: false)!
diff --git a/ios/LChatCore/Sources/LChatCore/SealedBranding.swift b/ios/LChatCore/Sources/LChatCore/SealedBranding.swift
new file mode 100644
index 0000000..76ac82e
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/SealedBranding.swift
@@ -0,0 +1,138 @@
+import CommonCrypto
+import Foundation
+
+// Sealed instance branding — the native rendering path (docs/no-plaintext.md).
+//
+// A sealed instance stores its branding (name, description, admin identity,
+// photo) as ciphertext under a short link-carried branding key B. Members
+// don't need a link: a copy of B rides `/api/group`'s member read sealed
+// under the *instance key*, which every member device can unwrap. This file
+// is the whole native pipeline: PBKDF2 B → AES key (byte-compatible with
+// crypto.js `deriveBrandingKey` — the pinned vector in test/crypto.test.mjs
+// and SealedBrandingTests must both pass forever), the blob shapes, and the
+// resolver that bootstraps + Keychain-caches B per instance.
+
+/// The decrypted branding set — field names match the web blob exactly.
+public struct SealedBranding: Codable, Sendable {
+ public var name: String?
+ public var description: String?
+ public var admin_name: String?
+ public var admin_email: String?
+ public var avatar_alt: String?
+ public var avatar_mime: String?
+}
+
+public enum SealedBrandingCrypto {
+ /// crypto.js BRANDING_KDF_ITERATIONS — changing this bricks every sealed
+ /// blob at rest; the pinned-vector tests exist to make that loud.
+ public static let kdfIterations: UInt32 = 600_000
+
+ /// PBKDF2-SHA256(B, salt) → the AES-GCM-256 branding key. CommonCrypto
+ /// because CryptoKit has no PBKDF2 (and the web can't have Argon2 without
+ /// a build step — see docs/no-plaintext.md § KDF).
+ public static func deriveKey(b: String, saltB64url: String) throws -> GroupKey {
+ guard !b.isEmpty, let salt = Data(base64urlEncoded: saltB64url), !salt.isEmpty else {
+ throw CryptoError.badKey
+ }
+ var out = Data(count: 32)
+ let status = out.withUnsafeMutableBytes { (outPtr: UnsafeMutableRawBufferPointer) in
+ salt.withUnsafeBytes { (saltPtr: UnsafeRawBufferPointer) in
+ CCKeyDerivationPBKDF(
+ CCPBKDFAlgorithm(kCCPBKDF2),
+ b, b.utf8.count,
+ saltPtr.bindMemory(to: UInt8.self).baseAddress, salt.count,
+ CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
+ kdfIterations,
+ outPtr.bindMemory(to: UInt8.self).baseAddress, 32)
+ }
+ }
+ guard status == kCCSuccess else { throw CryptoError.badKey }
+ return try GroupKey(rawKey: out)
+ }
+}
+
+/// The member read's sealed-branding fields (`/api/group?token=…`).
+public struct SealedBrandingRead: Decodable, Sendable {
+ public let sealed: Bool?
+ public let branding_blob: String?
+ public let branding_salt: String?
+ public let branding_key_sealed: String?
+}
+
+public enum SealedBrandingResolver {
+ /// The instance-key copy of B: web stores `encrypt(instanceKey, {b})`.
+ private struct WrappedB: Codable { let b: String }
+ private struct CachedB: Codable { let b: String }
+
+ /// Keyed by full origin, not bare host — two dev instances on
+ /// localhost:8081/8082 must not ping-pong each other's cache.
+ private static func cacheAccount(forOrigin origin: String) -> String {
+ "branding-b:" + origin
+ }
+
+ /// Drop the cached B for an instance — call when the user removes the
+ /// space from this device (B opens the publicly-served avatar ciphertext,
+ /// so it must not outlive membership on this device).
+ public static func clearCache(origin: String) {
+ Keychain.clear(account: cacheAccount(forOrigin: origin))
+ }
+
+ /// Opens a sealed instance's branding for a signed-in member: fetches the
+ /// member group read, resolves B (Keychain cache first, else the
+ /// instance-key copy — unwrapped with the member's private key), derives,
+ /// and decrypts. A stale cached B (the admin re-sealed under a fresh key)
+ /// falls back to the instance-key copy and repairs the cache, mirroring
+ /// the web's bootstrapBranding. Returns nil when the instance isn't
+ /// sealed or nothing decrypts — callers keep their generic fallback.
+ ///
+ /// COSTLY: an authed GET plus a 600k-iteration PBKDF2 (~0.1–0.5 s CPU).
+ /// Callers must latch the result — never call this per socket frame.
+ public static func open(api: LChatAPI, userPrivPkcs8B64: String) async -> (branding: SealedBranding, key: GroupKey)? {
+ guard let read = await api.sealedBrandingRead(),
+ read.sealed == true,
+ let blob = read.branding_blob, !blob.isEmpty,
+ let salt = read.branding_salt, !salt.isEmpty else { return nil }
+
+ let account = cacheAccount(forOrigin: api.baseURL.absoluteString)
+ if let cached = Keychain.load(CachedB.self, account: account),
+ let opened = decrypt(blob: blob, b: cached.b, salt: salt) {
+ return opened
+ }
+ // No usable cache: unwrap the current B from the instance-key copy.
+ guard let keySealed = read.branding_key_sealed, !keySealed.isEmpty,
+ let wrappedIK = await api.instanceKeyWrapped(),
+ let rawIK = try? ECIES.unwrap(wrappedB64: wrappedIK, privPkcs8B64: userPrivPkcs8B64),
+ let ik = try? GroupKey(rawKey: rawIK),
+ let wrapped = try? ik.decryptJSON(WrappedB.self, blob: keySealed),
+ let opened = decrypt(blob: blob, b: wrapped.b, salt: salt) else { return nil }
+ _ = Keychain.save(CachedB(b: wrapped.b), account: account)
+ return opened
+ }
+
+ private static func decrypt(blob: String, b: String, salt: String) -> (branding: SealedBranding, key: GroupKey)? {
+ guard let key = try? SealedBrandingCrypto.deriveKey(b: b, saltB64url: salt),
+ let branding = try? key.decryptJSON(SealedBranding.self, blob: blob) else { return nil }
+ return (branding, key)
+ }
+}
+
+public extension LChatAPI {
+ /// The sealed-branding fields of the member group read; nil off-network.
+ func sealedBrandingRead() async -> SealedBrandingRead? {
+ try? await getJSON("/api/group", SealedBrandingRead.self, authed: true)
+ }
+
+ /// The sealed photo, decrypted: /api/server-avatar serves base64url
+ /// `iv||ct` text under the sealed content type; only a branding-key
+ /// holder can turn it back into image bytes.
+ func serverAvatarSealed(key: GroupKey) async -> Data? {
+ guard let url = URL(string: "/api/server-avatar", relativeTo: baseURL),
+ let (data, resp) = try? await session.data(from: url),
+ let http = resp as? HTTPURLResponse, http.statusCode == 200,
+ http.value(forHTTPHeaderField: "Content-Type") == "application/vnd.lchat.sealed",
+ let text = String(data: data, encoding: .utf8),
+ let sealed = Data(base64urlEncoded: text)
+ else { return nil }
+ return try? key.decryptBytes(sealed)
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index be99ecb..62d739d 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -23,6 +23,10 @@ public final class ThreadStore: ObservableObject {
public let instance: SavedInstance
public private(set) var myUserId: String
private var instanceKeyB64: String? // for member avatars (optional)
+ // Sealed-branding latch: the decrypted name, resolved at most once per
+ // store lifetime (see refresh — the resolver is a 600k-iteration PBKDF2).
+ private var sealedInstanceName: String?
+ private var sealedNameAttempted = false
private var tierKeysB64: [String: String] = [:] // level id -> our unwrapped key
private var tiers: [TierRow] = [] // /api/tiers rows, personalised
private var rosterLabels: [String: String] = [:]
@@ -108,7 +112,25 @@ public final class ThreadStore: ObservableObject {
myUserId = me.user_id
}
let wrappedInstanceKey = await api.instanceKeyWrapped()
- instanceName = await api.groupName()
+ // Sealed branding: groupName() deliberately degrades to "" on a sealed
+ // instance — we're a member, so open the blob via the instance-key
+ // copy of B (SealedBranding.swift). LATCHED to one resolution per
+ // store lifetime: refresh() refires on every socket frame, and the
+ // resolver costs an authed GET plus a 600k-iteration PBKDF2 — the
+ // fetched "" must never clobber a name we already resolved.
+ let fetchedName = await api.groupName()
+ if !fetchedName.isEmpty {
+ instanceName = fetchedName
+ } else if let sealed = sealedInstanceName {
+ instanceName = sealed
+ } else if !sealedNameAttempted {
+ sealedNameAttempted = true
+ if let opened = await SealedBrandingResolver.open(api: api, userPrivPkcs8B64: instance.userPriv),
+ let name = opened.branding.name, !name.isEmpty {
+ sealedInstanceName = name
+ instanceName = name
+ }
+ }
let serverThreads = await api.threads()
let tierRows = await api.tiers()
// nil = fetch failed (keep what we hold); "" = genuinely never set.
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SealedBrandingTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SealedBrandingTests.swift
new file mode 100644
index 0000000..d129b00
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/SealedBrandingTests.swift
@@ -0,0 +1,54 @@
+import XCTest
+@testable import LChatCore
+
+// The sealed-branding KDF must agree byte-for-byte with the web
+// (crypto.js deriveBrandingKey) — the pinned vector here is the SAME one
+// test/crypto.test.mjs pins. If this fails you changed the derivation;
+// don't re-pin — sealed blobs already at rest would silently brick.
+final class SealedBrandingTests: XCTestCase {
+ private let vectorB = "k3m7p2qx4f"
+ private let vectorSalt = "AAECAwQFBgcICQoLDA0ODw" // bytes 0x00..0x0f
+ private let vectorBlob =
+ "uu9h74mrboFkgWSzYhnd67QKxlD7k9t6nJ79flw9UKkdkTMIZRTtxOcBW-ivDIckbudRf9uB64AXiGB4qcPfOA"
+
+ func testPinnedWebVectorDecrypts() throws {
+ struct Pin: Codable { let name: String; let emoji: String }
+ let key = try SealedBrandingCrypto.deriveKey(b: vectorB, saltB64url: vectorSalt)
+ let pin = try key.decryptJSON(Pin.self, blob: vectorBlob)
+ XCTAssertEqual(pin.name, "Vector Pin")
+ XCTAssertEqual(pin.emoji, "🔏")
+ }
+
+ func testWrongKeyOrSaltFailsClosed() throws {
+ let wrongB = try SealedBrandingCrypto.deriveKey(b: "aaaaaaaaaa", saltB64url: vectorSalt)
+ XCTAssertThrowsError(try wrongB.decryptJSON(SealedBranding.self, blob: vectorBlob))
+ let wrongSalt = try SealedBrandingCrypto.deriveKey(b: vectorB, saltB64url: "_w0ODQwLCgkIBwYFBAMCAQA")
+ XCTAssertThrowsError(try wrongSalt.decryptJSON(SealedBranding.self, blob: vectorBlob))
+ }
+
+ func testEmptyInputsThrow() {
+ XCTAssertThrowsError(try SealedBrandingCrypto.deriveKey(b: "", saltB64url: vectorSalt))
+ XCTAssertThrowsError(try SealedBrandingCrypto.deriveKey(b: vectorB, saltB64url: ""))
+ }
+
+ // The sealed avatar path: bytes encrypted under the derived key round-trip
+ // through the same iv||ct format the web's encryptBytes produces.
+ func testAvatarBytesRoundTrip() throws {
+ let key = try SealedBrandingCrypto.deriveKey(b: vectorB, saltB64url: vectorSalt)
+ let photo = Data([0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3, 4, 5])
+ let sealed = try key.encryptBytes(photo)
+ XCTAssertEqual(try key.decryptBytes(sealed), photo)
+ }
+
+ // The blob's field names are the contract with the web (sealBrandingNow's
+ // JSON) — decode a literal to keep them pinned.
+ func testBrandingBlobFieldNames() throws {
+ let json = """
+ {"name":"Family","description":"Us","admin_name":"Paul",
+ "admin_email":"p@x.com","avatar_alt":"A sunflower","avatar_mime":"image/jpeg"}
+ """.data(using: .utf8)!
+ let b = try JSONDecoder().decode(SealedBranding.self, from: json)
+ XCTAssertEqual(b.name, "Family")
+ XCTAssertEqual(b.avatar_mime, "image/jpeg")
+ }
+}