eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import CommonCrypto
2import 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.
16public 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
25public 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=…`).
55public 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
62public 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
119public 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