eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import 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.
14public 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
122public enum HomeCryptoError: Error { case badBlob, tooLarge }
123