eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import 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
15public 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.
39public 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
148public 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
235public 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