1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142 | import Foundation
import CryptoKit
/// Every derivation of one room (PROTOCOL.md): ids, per-sender session keys,
/// the mailbox room id and the room-wide at-rest key. All from
/// `HKDF-SHA256(ikm: secret, salt: UTF8(appSalt), info: label)`.
///
/// A value type: the owning actor holds it as `var` because sealing advances
/// counters and opening fills a bounded key cache.
public struct RoomCrypto: Sendable {
public let roomId: RoomId
public let peerId: PeerId
public let appSalt: String
private let ikm: Data
private let salt: Data
private var sigSealer: Sealer
private var msgSealer: Sealer
private var keyCache = KeyCache(limit: 512)
public init(secret: RoomSecret, appSalt: String, peerId: PeerId) {
self.init(secret: secret, appSalt: appSalt, peerId: peerId, epoch: nil)
}
/// Deterministic epochs — tests and vector reproduction only.
init(secret: RoomSecret, appSalt: String, peerId: PeerId, epoch: Data?) {
ikm = secret.bytes
salt = Data(appSalt.utf8)
self.appSalt = appSalt
self.peerId = peerId
roomId = RoomId(unchecked: Bytes.base64URL(KDF.deriveBytes(ikm: ikm, salt: salt, info: "roomid")))
let kSig = KDF.deriveKey(ikm: ikm, salt: salt, info: "signal|\(peerId.hex)")
let kMsg = KDF.deriveKey(ikm: ikm, salt: salt, info: "msg|\(peerId.hex)")
if let epoch {
sigSealer = Sealer(key: kSig, epoch: epoch)
msgSealer = Sealer(key: kMsg, epoch: epoch)
} else {
sigSealer = Sealer(key: kSig)
msgSealer = Sealer(key: kMsg)
}
}
/// Derives the room id alone (no session state), e.g. to look up a room.
public static func roomId(secret: RoomSecret, appSalt: String) -> RoomId {
RoomId(unchecked: Bytes.base64URL(KDF.deriveBytes(ikm: secret.bytes, salt: Data(appSalt.utf8), info: "roomid")))
}
// MARK: derivations shared with the reference implementation
/// Node-visible mailbox room id — its own derivation so the mailbox cannot be correlated with the relay room.
public var mailboxRoomId: String { Bytes.base64URL(KDF.deriveBytes(ikm: ikm, salt: salt, info: "mailbox|room")) }
/// Gossip topic of the reference implementation (unused by this SDK; kept for vector parity).
public var roomTopic: String { "sueta/2/" + Bytes.base64URL(KDF.deriveBytes(ikm: ikm, salt: salt, info: "topic|room")) }
/// Database name of the reference implementation (unused by this SDK; kept for vector parity).
public var dbName: String { Bytes.base64URL(KDF.deriveBytes(ikm: ikm, salt: salt, info: "dbname")) }
// MARK: AADs
func sigAAD(from: PeerId, to: PeerId) -> Data { Data("v1|\(roomId.string)|\(from.hex)|\(to.hex)|sig".utf8) }
func msgAAD(from: PeerId) -> Data { Data("v1|\(roomId.string)|\(from.hex)|msg".utf8) }
func atRestAAD(_ suffix: String) -> Data { Data("v2|\(roomId.string)|\(suffix)".utf8) }
// MARK: sealing (as the local peer)
/// Seals a directed payload for `to` under this peer's signal key.
public mutating func sealSignal(_ payload: JSONValue, to: PeerId) -> Envelope {
sigSealer.seal(payload, aad: sigAAD(from: peerId, to: to))
}
/// Seals a broadcast payload under this peer's message key (same ciphertext for every recipient).
public mutating func sealMessage(_ payload: JSONValue) -> Envelope {
msgSealer.seal(payload, aad: msgAAD(from: peerId))
}
/// Seals raw bytes under this peer's message key: `0x01 ‖ iv ‖ ct‖tag`.
public mutating func sealMessageBinary(_ data: Data) -> Data {
msgSealer.sealBinary(data, aad: msgAAD(from: peerId))
}
// MARK: opening (from a remote peer) — nil means drop silently
public mutating func openSignal(_ envelope: Envelope, from: PeerId) -> JSONValue? {
open(envelope, key: key("signal|\(from.hex)"), aad: sigAAD(from: from, to: peerId))
}
public mutating func openMessage(_ envelope: Envelope, from: PeerId) -> JSONValue? {
open(envelope, key: key("msg|\(from.hex)"), aad: msgAAD(from: from))
}
public mutating func openMessageBinary(_ data: Data, from: PeerId) -> Data? {
guard let (iv, ct) = AEAD.splitBinary(data, expectedPrefix: BinaryEnvelopePrefix.session) else { return nil }
return AEAD.open(ct, key: key("msg|\(from.hex)"), iv: iv, aad: msgAAD(from: from))
}
private func open(_ envelope: Envelope, key: SymmetricKey, aad: Data) -> JSONValue? {
guard let pt = AEAD.open(envelope.ciphertext, key: key, iv: envelope.iv, aad: aad) else { return nil }
return try? JSONValue.parse(pt)
}
// MARK: room-wide at-rest ciphers
/// Mailbox payload cipher (`mailbox-blob`, AAD `v2|room|mbx`).
public var mailboxCipher: AtRestCipher { atRest(info: "mailbox-blob", suffix: "mbx") }
/// Reference-implementation ciphers, kept for vector parity only.
var logEntryCipher: AtRestCipher { atRest(info: "log-entry", suffix: "entry") }
var logPayloadCipher: AtRestCipher { atRest(info: "log-payload", suffix: "payload") }
var imageCipher: AtRestCipher { atRest(info: "img", suffix: "img") }
private func atRest(info: String, suffix: String) -> AtRestCipher {
AtRestCipher(key: KDF.deriveKey(ikm: ikm, salt: salt, info: info), aad: atRestAAD(suffix))
}
// MARK: key cache
private mutating func key(_ info: String) -> SymmetricKey {
if let k = keyCache.get(info) { return k }
let k = KDF.deriveKey(ikm: ikm, salt: salt, info: info)
keyCache.put(info, k)
return k
}
}
/// FIFO-bounded cache: remote sender ids are attacker-mintable, so the cache
/// must not grow without bound.
struct KeyCache: Sendable {
let limit: Int
private var order: [String] = []
private var keys: [String: SymmetricKey] = [:]
init(limit: Int) { self.limit = limit }
func get(_ k: String) -> SymmetricKey? { keys[k] }
mutating func put(_ k: String, _ v: SymmetricKey) {
if keys[k] == nil {
order.append(k)
if order.count > limit { keys.removeValue(forKey: order.removeFirst()) }
}
keys[k] = v
}
var count: Int { keys.count }
}
|