| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | |
| 4 | // π€ The home-server backup crypto, byte-compatible with home/web/static/crypto.js |
| 5 | // so a blob written by the web (or another device) opens here and vice-versa. |
| 6 | // The passkey's PRF output derives a per-credential KEK (HKDF); a random DEK |
| 7 | // encrypts the padded blob (AES-GCM); the DEK is wrapped to each enrolled |
| 8 | // passkey's KEK. The server only ever stores the opaque envelope. |
| 9 | // |
| 10 | // Wire formats mirrored EXACTLY (see crypto.js): encryptBytes = iv(12)||ct||tag, |
| 11 | // KEK = HKDF-SHA256(prf, salt:β
, info:"eleven-home-kek"), DEK wrap = |
| 12 | // b64url(encryptBytes(kek, rawDEK)), plaintext padded to 16 KiB with a 4-byte |
| 13 | // big-endian length prefix, envelope = "EHB1"||u32 headerLen||headerJSON||body. |
| 14 | public enum HomeCrypto { |
| 15 | public static let blobPad = 16384 |
| 16 | |
| 17 | // The fixed, public PRF eval input β secrecy lives in the passkey, not here. |
| 18 | public static var prfEvalInput: Data { Data("eleven-home-kek-v1".utf8) } |
| 19 | |
| 20 | // ---- primitives (iv(12)||ct||tag, matching WebCrypto AES-GCM) ----------- |
| 21 | |
| 22 | static func encryptBytes(key: SymmetricKey, _ plain: Data) throws -> Data { |
| 23 | let iv = AES.GCM.Nonce() // 12 random bytes |
| 24 | let sealed = try AES.GCM.seal(plain, using: key, nonce: iv) |
| 25 | // WebCrypto appends the 16-byte tag to the ciphertext; CryptoKit keeps |
| 26 | // them apart. Concatenate to match: iv || ciphertext || tag. |
| 27 | return Data(iv) + sealed.ciphertext + sealed.tag |
| 28 | } |
| 29 | |
| 30 | static func decryptBytes(key: SymmetricKey, _ combined: Data) throws -> Data { |
| 31 | let iv = combined.prefix(12) |
| 32 | let rest = combined.dropFirst(12) |
| 33 | let ct = rest.dropLast(16) |
| 34 | let tag = rest.suffix(16) |
| 35 | let box = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), |
| 36 | ciphertext: ct, tag: tag) |
| 37 | return try AES.GCM.open(box, using: key) |
| 38 | } |
| 39 | |
| 40 | // ---- PRF β KEK ---------------------------------------------------------- |
| 41 | |
| 42 | public static func kekFromPRF(_ prfOutput: Data) -> SymmetricKey { |
| 43 | HKDF<SHA256>.deriveKey( |
| 44 | inputKeyMaterial: SymmetricKey(data: prfOutput), |
| 45 | salt: Data(), |
| 46 | info: Data("eleven-home-kek".utf8), |
| 47 | outputByteCount: 32) |
| 48 | } |
| 49 | |
| 50 | // ---- DEK wrap / unwrap -------------------------------------------------- |
| 51 | |
| 52 | public static func wrapDEK(_ dek: SymmetricKey, kek: SymmetricKey) throws -> String { |
| 53 | let raw = dek.withUnsafeBytes { Data($0) } |
| 54 | return try encryptBytes(key: kek, raw).base64urlEncodedString() |
| 55 | } |
| 56 | |
| 57 | public static func unwrapDEK(_ wrappedB64: String, kek: SymmetricKey) throws -> SymmetricKey { |
| 58 | guard let wrapped = Data(base64urlEncoded: wrappedB64) else { throw HomeCryptoError.badBlob } |
| 59 | return SymmetricKey(data: try decryptBytes(key: kek, wrapped)) |
| 60 | } |
| 61 | |
| 62 | // ---- fixed-size padding (length leaks nothing) -------------------------- |
| 63 | |
| 64 | static func pad(_ bytes: Data) throws -> Data { |
| 65 | guard bytes.count <= blobPad - 4 else { throw HomeCryptoError.tooLarge } |
| 66 | var out = Data(count: blobPad) |
| 67 | let len = UInt32(bytes.count).bigEndian |
| 68 | withUnsafeBytes(of: len) { out.replaceSubrange(0..<4, with: $0) } |
| 69 | out.replaceSubrange(4..<(4 + bytes.count), with: bytes) |
| 70 | return out |
| 71 | } |
| 72 | |
| 73 | static func unpad(_ bytes: Data) throws -> Data { |
| 74 | guard bytes.count >= 4 else { throw HomeCryptoError.badBlob } |
| 75 | let len = bytes.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } |
| 76 | let start = 4, end = 4 + Int(len) |
| 77 | guard end <= bytes.count else { throw HomeCryptoError.badBlob } |
| 78 | return bytes.subdata(in: start..<end) |
| 79 | } |
| 80 | |
| 81 | // ---- the envelope: "EHB1" || u32 headerLen || header || iv||ct||tag ------ |
| 82 | |
| 83 | private static let magic = Data("EHB1".utf8) |
| 84 | |
| 85 | /// Split an envelope into (wrapped-key header, encrypted body) without |
| 86 | /// decrypting β to find our wrapped DEK and to re-seal preserving others'. |
| 87 | public static func unpack(_ bytes: Data) throws -> (keys: [String: String], body: Data) { |
| 88 | guard bytes.count >= 8, bytes.prefix(4) == magic else { throw HomeCryptoError.badBlob } |
| 89 | let headerLen = bytes.subdata(in: 4..<8).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } |
| 90 | let headerEnd = 8 + Int(headerLen) |
| 91 | guard headerEnd <= bytes.count else { throw HomeCryptoError.badBlob } |
| 92 | let headerData = bytes.subdata(in: 8..<headerEnd) |
| 93 | struct Header: Decodable { let keys: [String: String]? } |
| 94 | let header = try JSONDecoder().decode(Header.self, from: headerData) |
| 95 | return (header.keys ?? [:], bytes.subdata(in: headerEnd..<bytes.count)) |
| 96 | } |
| 97 | |
| 98 | /// Decrypt an envelope's body with an already-unwrapped DEK β the JSON obj. |
| 99 | public static func openBlob(_ bytes: Data, dek: SymmetricKey) throws -> (obj: Data, keys: [String: String]) { |
| 100 | let (keys, body) = try unpack(bytes) |
| 101 | let obj = try unpad(decryptBytes(key: dek, body)) |
| 102 | return (obj, keys) |
| 103 | } |
| 104 | |
| 105 | /// Seal a JSON obj (raw bytes) under the DEK, packed with the wrapped-key |
| 106 | /// header. `keys` is [credentialId: wrappedDEK]. |
| 107 | public static func sealBlob(objJSON: Data, dek: SymmetricKey, keys: [String: String]) throws -> Data { |
| 108 | let body = try encryptBytes(key: dek, pad(objJSON)) |
| 109 | let header = try JSONSerialization.data(withJSONObject: ["keys": keys], |
| 110 | options: [.sortedKeys]) |
| 111 | var out = magic |
| 112 | var headerLen = UInt32(header.count).bigEndian |
| 113 | out.append(Data(bytes: &headerLen, count: 4)) |
| 114 | out.append(header) |
| 115 | out.append(body) |
| 116 | return out |
| 117 | } |
| 118 | |
| 119 | public static func generateDEK() -> SymmetricKey { SymmetricKey(size: .bits256) } |
| 120 | } |
| 121 | |
| 122 | public enum HomeCryptoError: Error { case badBlob, tooLarge } |
| 123 | |