| 1 | import Foundation |
| 2 | import CryptoKit |
| 3 | |
| 4 | // π€ The invite-link key derivation, byte-compatible with web/static/crypto.js |
| 5 | // (deriveInvite / randomSecret) and invite.js (buildInviteFragment): the |
| 6 | // secret T rides ONLY in the link's #fragment; the server stores the derived |
| 7 | // lookup id + claim hash + wrapped blobs it can't open. See |
| 8 | // docs/invite-redesign.md. |
| 9 | public enum InviteCrypto { |
| 10 | public struct Derived { |
| 11 | public let id: String // b64url HKDF("lchat-invite-id") |
| 12 | public let wrapKey: GroupKey // AES-GCM from HKDF("lchat-invite-wrap") |
| 13 | public let claimSecret: String // b64url HKDF("lchat-invite-claim") β sent raw to CLAIM |
| 14 | public let claimHash: String // lowercase hex SHA-256 of the claim secret (stored on MINT) |
| 15 | public let bindKey: String // b64url HKDF("lchat-invite-bind") β key transparency (#573) |
| 16 | } |
| 17 | |
| 18 | /// A fresh 128-bit invite secret (crypto.js randomSecret). |
| 19 | public static func randomSecret() -> String { |
| 20 | var bytes = [UInt8](repeating: 0, count: 16) |
| 21 | _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) |
| 22 | return Data(bytes).base64urlEncodedString() |
| 23 | } |
| 24 | |
| 25 | public static func derive(_ tB64: String) throws -> Derived { |
| 26 | guard let t = Data(base64urlEncoded: tB64) else { throw CryptoError.badBlob } |
| 27 | let ikm = SymmetricKey(data: t) |
| 28 | func bits(_ info: String) -> Data { |
| 29 | HKDF<SHA256>.deriveKey(inputKeyMaterial: ikm, salt: Data(), |
| 30 | info: Data(info.utf8), outputByteCount: 32) |
| 31 | .withUnsafeBytes { Data($0) } |
| 32 | } |
| 33 | let claimSecret = bits("lchat-invite-claim").base64urlEncodedString() |
| 34 | let claimHash = SHA256.hash(data: Data(claimSecret.utf8)) |
| 35 | .map { String(format: "%02x", $0) }.joined() |
| 36 | return Derived(id: bits("lchat-invite-id").base64urlEncodedString(), |
| 37 | wrapKey: try GroupKey(rawKey: bits("lchat-invite-wrap")), |
| 38 | claimSecret: claimSecret, |
| 39 | claimHash: claimHash, |
| 40 | bindKey: bits("lchat-invite-bind").base64urlEncodedString()) |
| 41 | } |
| 42 | |
| 43 | // Key transparency (#573, docs/tamper-evidence.md): the joiner MACs its |
| 44 | // published public key under bindKey (derived from T) so a T-holder (the |
| 45 | // inviter) can verify the roster key wasn't substituted. Byte-compatible |
| 46 | // with crypto.js bindMac/verifyBindMac β HMAC-SHA256 over the DECODED |
| 47 | // public-key bytes (not the base64url string), so a server re-encoding the |
| 48 | // roster string can't break an honest member's verify. |
| 49 | |
| 50 | /// Sign a public key: bindMac(bindKey, pub) = HMAC-SHA256(decode(pub)) keyed |
| 51 | /// by decode(bindKey). Inputs and output are base64url. Returns nil on |
| 52 | /// malformed input. |
| 53 | public static func bindMac(bindKeyB64: String, pubB64: String) -> String? { |
| 54 | guard let keyData = Data(base64urlEncoded: bindKeyB64), |
| 55 | let pubData = Data(base64urlEncoded: pubB64) else { return nil } |
| 56 | let mac = HMAC<SHA256>.authenticationCode(for: pubData, using: SymmetricKey(data: keyData)) |
| 57 | return Data(mac).base64urlEncodedString() |
| 58 | } |
| 59 | |
| 60 | /// Verify a bind-MAC β false (never throws) on any malformed input. |
| 61 | public static func verifyBindMac(bindKeyB64: String, pubB64: String, macB64: String) -> Bool { |
| 62 | guard !bindKeyB64.isEmpty, !pubB64.isEmpty, !macB64.isEmpty, |
| 63 | let keyData = Data(base64urlEncoded: bindKeyB64), |
| 64 | let pubData = Data(base64urlEncoded: pubB64), |
| 65 | let macData = Data(base64urlEncoded: macB64) else { return false } |
| 66 | return HMAC<SHA256>.isValidAuthenticationCode(macData, authenticating: pubData, using: SymmetricKey(data: keyData)) |
| 67 | } |
| 68 | |
| 69 | /// invite.js buildInviteFragment: "e1.<T>" or "e1.<T>~<encoded name>". |
| 70 | /// "~" is escaped in the name (unlike encodeURIComponent) so it can never |
| 71 | /// collide with the slot separator β the web builder does the same. |
| 72 | public static func fragment(_ tB64: String, name: String) -> String { |
| 73 | var allowed = CharacterSet.alphanumerics |
| 74 | allowed.insert(charactersIn: "-_.!*'()") // encodeURIComponent's survivors, minus "~" |
| 75 | let encoded = name.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" |
| 76 | return "e1." + tB64 + (encoded.isEmpty ? "" : "~" + encoded) |
| 77 | } |
| 78 | } |
| 79 | |