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// πŸ€– 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.
9public 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