| 1 | import CommonCrypto |
| 2 | import Foundation |
| 3 | |
| 4 | // Sealed instance branding — the native rendering path (docs/no-plaintext.md). |
| 5 | // |
| 6 | // A sealed instance stores its branding (name, description, admin identity, |
| 7 | // photo) as ciphertext under a short link-carried branding key B. Members |
| 8 | // don't need a link: a copy of B rides `/api/group`'s member read sealed |
| 9 | // under the *instance key*, which every member device can unwrap. This file |
| 10 | // is the whole native pipeline: PBKDF2 B → AES key (byte-compatible with |
| 11 | // crypto.js `deriveBrandingKey` — the pinned vector in test/crypto.test.mjs |
| 12 | // and SealedBrandingTests must both pass forever), the blob shapes, and the |
| 13 | // resolver that bootstraps + Keychain-caches B per instance. |
| 14 | |
| 15 | /// The decrypted branding set — field names match the web blob exactly. |
| 16 | public struct SealedBranding: Codable, Sendable { |
| 17 | public var name: String? |
| 18 | public var description: String? |
| 19 | public var admin_name: String? |
| 20 | public var admin_email: String? |
| 21 | public var avatar_alt: String? |
| 22 | public var avatar_mime: String? |
| 23 | } |
| 24 | |
| 25 | public enum SealedBrandingCrypto { |
| 26 | /// crypto.js BRANDING_KDF_ITERATIONS — changing this bricks every sealed |
| 27 | /// blob at rest; the pinned-vector tests exist to make that loud. |
| 28 | public static let kdfIterations: UInt32 = 600_000 |
| 29 | |
| 30 | /// PBKDF2-SHA256(B, salt) → the AES-GCM-256 branding key. CommonCrypto |
| 31 | /// because CryptoKit has no PBKDF2 (and the web can't have Argon2 without |
| 32 | /// a build step — see docs/no-plaintext.md § KDF). |
| 33 | public static func deriveKey(b: String, saltB64url: String) throws -> GroupKey { |
| 34 | guard !b.isEmpty, let salt = Data(base64urlEncoded: saltB64url), !salt.isEmpty else { |
| 35 | throw CryptoError.badKey |
| 36 | } |
| 37 | var out = Data(count: 32) |
| 38 | let status = out.withUnsafeMutableBytes { (outPtr: UnsafeMutableRawBufferPointer) in |
| 39 | salt.withUnsafeBytes { (saltPtr: UnsafeRawBufferPointer) in |
| 40 | CCKeyDerivationPBKDF( |
| 41 | CCPBKDFAlgorithm(kCCPBKDF2), |
| 42 | b, b.utf8.count, |
| 43 | saltPtr.bindMemory(to: UInt8.self).baseAddress, salt.count, |
| 44 | CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), |
| 45 | kdfIterations, |
| 46 | outPtr.bindMemory(to: UInt8.self).baseAddress, 32) |
| 47 | } |
| 48 | } |
| 49 | guard status == kCCSuccess else { throw CryptoError.badKey } |
| 50 | return try GroupKey(rawKey: out) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /// The member read's sealed-branding fields (`/api/group?token=…`). |
| 55 | public struct SealedBrandingRead: Decodable, Sendable { |
| 56 | public let sealed: Bool? |
| 57 | public let branding_blob: String? |
| 58 | public let branding_salt: String? |
| 59 | public let branding_key_sealed: String? |
| 60 | } |
| 61 | |
| 62 | public enum SealedBrandingResolver { |
| 63 | /// The instance-key copy of B: web stores `encrypt(instanceKey, {b})`. |
| 64 | private struct WrappedB: Codable { let b: String } |
| 65 | private struct CachedB: Codable { let b: String } |
| 66 | |
| 67 | /// Keyed by full origin, not bare host — two dev instances on |
| 68 | /// localhost:8081/8082 must not ping-pong each other's cache. |
| 69 | private static func cacheAccount(forOrigin origin: String) -> String { |
| 70 | "branding-b:" + origin |
| 71 | } |
| 72 | |
| 73 | /// Drop the cached B for an instance — call when the user removes the |
| 74 | /// space from this device (B opens the publicly-served avatar ciphertext, |
| 75 | /// so it must not outlive membership on this device). |
| 76 | public static func clearCache(origin: String) { |
| 77 | Keychain.clear(account: cacheAccount(forOrigin: origin)) |
| 78 | } |
| 79 | |
| 80 | /// Opens a sealed instance's branding for a signed-in member: fetches the |
| 81 | /// member group read, resolves B (Keychain cache first, else the |
| 82 | /// instance-key copy — unwrapped with the member's private key), derives, |
| 83 | /// and decrypts. A stale cached B (the admin re-sealed under a fresh key) |
| 84 | /// falls back to the instance-key copy and repairs the cache, mirroring |
| 85 | /// the web's bootstrapBranding. Returns nil when the instance isn't |
| 86 | /// sealed or nothing decrypts — callers keep their generic fallback. |
| 87 | /// |
| 88 | /// COSTLY: an authed GET plus a 600k-iteration PBKDF2 (~0.1–0.5 s CPU). |
| 89 | /// Callers must latch the result — never call this per socket frame. |
| 90 | public static func open(api: LChatAPI, userPrivPkcs8B64: String) async -> (branding: SealedBranding, key: GroupKey)? { |
| 91 | guard let read = await api.sealedBrandingRead(), |
| 92 | read.sealed == true, |
| 93 | let blob = read.branding_blob, !blob.isEmpty, |
| 94 | let salt = read.branding_salt, !salt.isEmpty else { return nil } |
| 95 | |
| 96 | let account = cacheAccount(forOrigin: api.baseURL.absoluteString) |
| 97 | if let cached = Keychain.load(CachedB.self, account: account), |
| 98 | let opened = decrypt(blob: blob, b: cached.b, salt: salt) { |
| 99 | return opened |
| 100 | } |
| 101 | // No usable cache: unwrap the current B from the instance-key copy. |
| 102 | guard let keySealed = read.branding_key_sealed, !keySealed.isEmpty, |
| 103 | let wrappedIK = await api.instanceKeyWrapped(), |
| 104 | let rawIK = try? ECIES.unwrap(wrappedB64: wrappedIK, privPkcs8B64: userPrivPkcs8B64), |
| 105 | let ik = try? GroupKey(rawKey: rawIK), |
| 106 | let wrapped = try? ik.decryptJSON(WrappedB.self, blob: keySealed), |
| 107 | let opened = decrypt(blob: blob, b: wrapped.b, salt: salt) else { return nil } |
| 108 | _ = Keychain.save(CachedB(b: wrapped.b), account: account) |
| 109 | return opened |
| 110 | } |
| 111 | |
| 112 | private static func decrypt(blob: String, b: String, salt: String) -> (branding: SealedBranding, key: GroupKey)? { |
| 113 | guard let key = try? SealedBrandingCrypto.deriveKey(b: b, saltB64url: salt), |
| 114 | let branding = try? key.decryptJSON(SealedBranding.self, blob: blob) else { return nil } |
| 115 | return (branding, key) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | public extension LChatAPI { |
| 120 | /// The sealed-branding fields of the member group read; nil off-network. |
| 121 | func sealedBrandingRead() async -> SealedBrandingRead? { |
| 122 | try? await getJSON("/api/group", SealedBrandingRead.self, authed: true) |
| 123 | } |
| 124 | |
| 125 | /// The sealed photo, decrypted: /api/server-avatar serves base64url |
| 126 | /// `iv||ct` text under the sealed content type; only a branding-key |
| 127 | /// holder can turn it back into image bytes. |
| 128 | func serverAvatarSealed(key: GroupKey) async -> Data? { |
| 129 | guard let url = URL(string: "/api/server-avatar", relativeTo: baseURL), |
| 130 | let (data, resp) = try? await session.data(from: url), |
| 131 | let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| 132 | http.value(forHTTPHeaderField: "Content-Type") == "application/vnd.lchat.sealed", |
| 133 | let text = String(data: data, encoding: .utf8), |
| 134 | let sealed = Data(base64urlEncoded: text) |
| 135 | else { return nil } |
| 136 | return try? key.decryptBytes(sealed) |
| 137 | } |
| 138 | } |
| 139 | |