| 1 | import Foundation |
| 2 | import CryptoKit |
| 3 | |
| 4 | // All message/attachment encryption happens on-device with the group's AES-GCM |
| 5 | // key, which lives only in the invite link's #fragment. The server sees nothing |
| 6 | // but the opaque blobs produced here — the exact same contract as web/static/ |
| 7 | // crypto.js, and the wire format is byte-identical: |
| 8 | // |
| 9 | // blob = iv(12) || ciphertext || tag(16) |
| 10 | // |
| 11 | // WebCrypto's AES-GCM output is ciphertext||tag and crypto.js prepends the IV, |
| 12 | // which is precisely CryptoKit's `AES.GCM.SealedBox.combined` (nonce||ct||tag). |
| 13 | // So blobs round-trip in both directions without any custom framing. |
| 14 | |
| 15 | public enum CryptoError: Error { |
| 16 | case badKey |
| 17 | case badBlob |
| 18 | case sealFailed |
| 19 | case badWrap |
| 20 | } |
| 21 | |
| 22 | // ECIES key-unwrap, byte-compatible with web/static/crypto.js `unwrapThreadKey`. |
| 23 | // |
| 24 | // In the multi-thread model each thread (and the instance) has its own AES key, |
| 25 | // wrapped to each member's user public key. A wrapped blob is, base64url-encoded: |
| 26 | // |
| 27 | // ephPub(65) || iv(12) || ciphertext||tag |
| 28 | // |
| 29 | // where ephPub is an ephemeral P-256 public point (uncompressed / x963). We recover |
| 30 | // the AES key with our *user private key* (PKCS#8): ECDH against ephPub, then |
| 31 | // HKDF-SHA256(salt = empty, info = "lchat-thread-wrap") to the same AES-GCM key the |
| 32 | // web derived, then open. The web uses the same "lchat-thread-wrap" info for both |
| 33 | // thread keys and the instance key, so this one path covers both. |
| 34 | // |
| 35 | // This is the *only* crypto duplicated from the web (everything else stays in |
| 36 | // chat.js, reached through the webview). The cross-implementation test vector in |
| 37 | // ios/LChatTests/thread-key-vector.json (generated by test/threadkey.test.mjs) |
| 38 | // guards it against drift. |
| 39 | public enum ECIES { |
| 40 | private static let info = Data("lchat-thread-wrap".utf8) |
| 41 | |
| 42 | /// A fresh random 256-bit AES key (for a new thread). |
| 43 | public static func randomAESKey() -> Data { |
| 44 | SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } |
| 45 | } |
| 46 | |
| 47 | /// A fresh user identity keypair, matching web/static/crypto.js |
| 48 | /// generateUserKeypair: pub = base64url raw (uncompressed x963) point, |
| 49 | /// priv = base64url PKCS#8 DER — the exact SavedInstance field formats, |
| 50 | /// so a natively-claimed identity round-trips through every existing |
| 51 | /// wrap/unwrap path (and through the web, via the home vault). |
| 52 | public static func generateUserKeypair() -> (pub: String, priv: String) { |
| 53 | let priv = P256.KeyAgreement.PrivateKey() |
| 54 | return (priv.publicKey.x963Representation.base64urlEncodedString(), |
| 55 | priv.derRepresentation.base64urlEncodedString()) |
| 56 | } |
| 57 | |
| 58 | /// Wraps a raw AES key (32 bytes) to a recipient's user public key (base64url |
| 59 | /// raw/x963), producing the base64url blob `ephPub(65) || iv(12) || ct||tag` — |
| 60 | /// byte-compatible with web/static/crypto.js `wrapThreadKey`. Used when starting |
| 61 | /// a chat: the new thread key is wrapped to each member (including yourself). |
| 62 | public static func wrap(rawKey: Data, recipientPubB64: String) throws -> String { |
| 63 | guard rawKey.count == 32 else { throw CryptoError.badWrap } |
| 64 | return try seal(bytes: rawKey, recipientPubB64: recipientPubB64) |
| 65 | } |
| 66 | |
| 67 | /// Seals arbitrary bytes to a recipient's public key — the twin of the |
| 68 | /// web's `sealForPub` (web/static/crypto.js): the exact wire shape of |
| 69 | /// `wrap`, without the 32-byte payload rule. Used for the per-viewer |
| 70 | /// nicknames blob (#577), sealed to one's OWN public key so only the |
| 71 | /// member's devices can open it. |
| 72 | public static func seal(bytes: Data, recipientPubB64: String) throws -> String { |
| 73 | guard let recipientRaw = Data(base64urlEncoded: recipientPubB64) else { |
| 74 | throw CryptoError.badWrap |
| 75 | } |
| 76 | do { |
| 77 | let recipient = try P256.KeyAgreement.PublicKey(x963Representation: recipientRaw) |
| 78 | let eph = P256.KeyAgreement.PrivateKey() |
| 79 | let shared = try eph.sharedSecretFromKeyAgreement(with: recipient) |
| 80 | let aes = shared.hkdfDerivedSymmetricKey( |
| 81 | using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32) |
| 82 | let box = try AES.GCM.seal(bytes, using: aes) |
| 83 | guard let combined = box.combined else { throw CryptoError.sealFailed } // iv||ct||tag |
| 84 | var out = Data(eph.publicKey.x963Representation) |
| 85 | out.append(combined) |
| 86 | return out.base64urlEncodedString() |
| 87 | } catch { |
| 88 | throw CryptoError.badWrap |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /// Opens a `seal`/`sealForPub` blob with the PKCS#8 private key string, |
| 93 | /// returning the plaintext bytes — `unwrap` without the thread-key length |
| 94 | /// rule (the web twin is `openSealed`). |
| 95 | public static func open(sealedB64: String, privPkcs8B64: String) throws -> Data { |
| 96 | guard let pkcs8 = Data(base64urlEncoded: privPkcs8B64), |
| 97 | let priv = try? P256.KeyAgreement.PrivateKey(derRepresentation: pkcs8) else { |
| 98 | throw CryptoError.badWrap |
| 99 | } |
| 100 | return try unwrapBytes(wrappedB64: sealedB64, priv: priv) |
| 101 | } |
| 102 | |
| 103 | /// Arbitrary-length variant of `unwrap`, taking a live private key: the |
| 104 | /// browser sign-in handoff wraps a JSON session (home's `wrapToPublicKey`, |
| 105 | /// same layout + info string), not a 32-byte thread key. |
| 106 | public static func unwrapBytes(wrappedB64: String, priv: P256.KeyAgreement.PrivateKey) throws -> Data { |
| 107 | guard let blob = Data(base64urlEncoded: wrappedB64), blob.count > 65 + 12 else { |
| 108 | throw CryptoError.badWrap |
| 109 | } |
| 110 | let ephPub = blob.prefix(65) |
| 111 | let sealed = blob.suffix(from: blob.startIndex + 65) // iv || ciphertext || tag |
| 112 | do { |
| 113 | let eph = try P256.KeyAgreement.PublicKey(x963Representation: ephPub) |
| 114 | let shared = try priv.sharedSecretFromKeyAgreement(with: eph) |
| 115 | let aes = shared.hkdfDerivedSymmetricKey( |
| 116 | using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32) |
| 117 | return try AES.GCM.open(AES.GCM.SealedBox(combined: sealed), using: aes) |
| 118 | } catch { |
| 119 | throw CryptoError.badWrap |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /// Unwraps a wrapped key blob (base64url) with a user private key (base64url |
| 124 | /// PKCS#8), returning the 32-byte raw AES key. |
| 125 | public static func unwrap(wrappedB64: String, privPkcs8B64: String) throws -> Data { |
| 126 | guard let blob = Data(base64urlEncoded: wrappedB64), blob.count > 65 + 12, |
| 127 | let pkcs8 = Data(base64urlEncoded: privPkcs8B64) else { |
| 128 | throw CryptoError.badWrap |
| 129 | } |
| 130 | let ephPub = blob.prefix(65) |
| 131 | let sealed = blob.suffix(from: blob.startIndex + 65) // iv || ciphertext || tag |
| 132 | do { |
| 133 | let priv = try P256.KeyAgreement.PrivateKey(derRepresentation: pkcs8) |
| 134 | let eph = try P256.KeyAgreement.PublicKey(x963Representation: ephPub) |
| 135 | let shared = try priv.sharedSecretFromKeyAgreement(with: eph) |
| 136 | let aes = shared.hkdfDerivedSymmetricKey( |
| 137 | using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32) |
| 138 | let box = try AES.GCM.SealedBox(combined: sealed) |
| 139 | let raw = try AES.GCM.open(box, using: aes) |
| 140 | guard raw.count == 32 else { throw CryptoError.badWrap } |
| 141 | return raw |
| 142 | } catch { |
| 143 | throw CryptoError.badWrap |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | public struct GroupKey { |
| 149 | private let key: SymmetricKey |
| 150 | |
| 151 | /// Imports a 256-bit key from the base64url string carried in `#k=…`. |
| 152 | public init(base64url: String) throws { |
| 153 | guard let raw = Data(base64urlEncoded: base64url), raw.count == 32 else { |
| 154 | throw CryptoError.badKey |
| 155 | } |
| 156 | key = SymmetricKey(data: raw) |
| 157 | } |
| 158 | |
| 159 | /// Wraps a raw 32-byte key (e.g. one just unwrapped via `ECIES.unwrap`). |
| 160 | public init(rawKey: Data) throws { |
| 161 | guard rawKey.count == 32 else { throw CryptoError.badKey } |
| 162 | key = SymmetricKey(data: rawKey) |
| 163 | } |
| 164 | |
| 165 | // MARK: JSON payloads (chat messages) |
| 166 | |
| 167 | public func decryptJSON<T: Decodable>(_ type: T.Type, blob: String) throws -> T { |
| 168 | let data = try decrypt(base64urlBlob: blob) |
| 169 | return try JSONDecoder().decode(T.self, from: data) |
| 170 | } |
| 171 | |
| 172 | public func encryptJSON<T: Encodable>(_ value: T) throws -> String { |
| 173 | let data = try JSONEncoder().encode(value) |
| 174 | return try encrypt(data).base64urlEncodedString() |
| 175 | } |
| 176 | |
| 177 | /// Decrypts a base64url blob to its raw JSON bytes — for dynamic payloads |
| 178 | /// (app-schema events, app-state snapshots) decoded via AppJSON rather |
| 179 | /// than a fixed Codable type. |
| 180 | public func decryptData(blob: String) throws -> Data { |
| 181 | try decrypt(base64urlBlob: blob) |
| 182 | } |
| 183 | |
| 184 | /// Encrypts raw JSON bytes to a base64url blob (the message-blob format). |
| 185 | public func encryptData(_ data: Data) throws -> String { |
| 186 | try encrypt(data).base64urlEncodedString() |
| 187 | } |
| 188 | |
| 189 | /// Decrypts a base64url blob whose plaintext is a bare JSON string — how the |
| 190 | /// web encrypts screen names (`encrypt(instanceKey, name)` → JSON.stringify). |
| 191 | /// Uses .fragmentsAllowed so a top-level `"Name"` decodes reliably. |
| 192 | public func decryptString(blob: String) throws -> String { |
| 193 | let data = try decrypt(base64urlBlob: blob) |
| 194 | guard let s = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? String else { |
| 195 | throw CryptoError.badBlob |
| 196 | } |
| 197 | return s |
| 198 | } |
| 199 | |
| 200 | // MARK: Binary payloads (attachments) |
| 201 | |
| 202 | /// Decrypts raw `iv||ct||tag` bytes (attachment downloads are octet-stream, |
| 203 | /// not base64). |
| 204 | public func decryptBytes(_ combined: Data) throws -> Data { |
| 205 | do { |
| 206 | let box = try AES.GCM.SealedBox(combined: combined) |
| 207 | return try AES.GCM.open(box, using: key) |
| 208 | } catch { |
| 209 | throw CryptoError.badBlob |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | /// Encrypts raw bytes to `iv||ct||tag` for upload. |
| 214 | public func encryptBytes(_ plaintext: Data) throws -> Data { |
| 215 | try encrypt(plaintext) |
| 216 | } |
| 217 | |
| 218 | // MARK: Internals |
| 219 | |
| 220 | private func decrypt(base64urlBlob: String) throws -> Data { |
| 221 | guard let combined = Data(base64urlEncoded: base64urlBlob) else { |
| 222 | throw CryptoError.badBlob |
| 223 | } |
| 224 | return try decryptBytes(combined) |
| 225 | } |
| 226 | |
| 227 | private func encrypt(_ plaintext: Data) throws -> Data { |
| 228 | // seal() picks a fresh random 12-byte nonce — exactly what the web does. |
| 229 | let box = try AES.GCM.seal(plaintext, using: key) |
| 230 | guard let combined = box.combined else { throw CryptoError.sealFailed } |
| 231 | return combined |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | public extension Data { |
| 236 | /// Decodes base64url (`-`/`_`, no padding), the encoding crypto.js uses. |
| 237 | init?(base64urlEncoded s: String) { |
| 238 | var str = s.replacingOccurrences(of: "-", with: "+") |
| 239 | .replacingOccurrences(of: "_", with: "/") |
| 240 | while str.count % 4 != 0 { str += "=" } |
| 241 | self.init(base64Encoded: str) |
| 242 | } |
| 243 | |
| 244 | func base64urlEncodedString() -> String { |
| 245 | base64EncodedString() |
| 246 | .replacingOccurrences(of: "+", with: "-") |
| 247 | .replacingOccurrences(of: "/", with: "_") |
| 248 | .replacingOccurrences(of: "=", with: "") |
| 249 | } |
| 250 | } |
| 251 | |