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>
5 files changed,
+245
−8
ios/LChat/LChatApp.swift+21 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+9 −7ios/LChatCore/Sources/LChatCore/SealedBranding.swift+138 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+23 −1ios/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") |
| + } |
| +} |