From 0ff5e2f9077ea7e62e2ed95cfcf3ca0972344c73 Mon Sep 17 00:00:00 2001 From: samuelzielke Date: Wed, 21 Oct 2026 19:05:32 +0200 Subject: [PATCH] feat(intercom): STUN, TLS PRF, SRTP and self-signed certificate Building blocks for a minimal WebRTC stack on CryptoKit/CommonCrypto, verified against RFC 5769, RFC 3711 and OpenSSL vectors. Co-Authored-By: Claude Opus 5.5 --- Packages/MusubiKit/Package.swift | 6 + .../Sources/Intercom/RTC/Bytes.swift | 109 +++++++++++++ .../Sources/Intercom/RTC/Certificate.swift | 144 ++++++++++++++++++ .../MusubiKit/Sources/Intercom/RTC/SRTP.swift | 139 +++++++++++++++++ .../MusubiKit/Sources/Intercom/RTC/STUN.swift | 141 +++++++++++++++++ .../Tests/IntercomTests/CryptoTests.swift | 118 ++++++++++++++ 6 files changed, 657 insertions(+) create mode 100644 Packages/MusubiKit/Sources/Intercom/RTC/Bytes.swift create mode 100644 Packages/MusubiKit/Sources/Intercom/RTC/Certificate.swift create mode 100644 Packages/MusubiKit/Sources/Intercom/RTC/SRTP.swift create mode 100644 Packages/MusubiKit/Sources/Intercom/RTC/STUN.swift create mode 100644 Packages/MusubiKit/Tests/IntercomTests/CryptoTests.swift diff --git a/Packages/MusubiKit/Package.swift b/Packages/MusubiKit/Package.swift index 8570a6e..2f08a35 100644 --- a/Packages/MusubiKit/Package.swift +++ b/Packages/MusubiKit/Package.swift @@ -12,6 +12,7 @@ let package = Package( .library(name: "AudioMatrix", targets: ["AudioMatrix"]), .library(name: "DesignSystem", targets: ["DesignSystem"]), .library(name: "Remote", targets: ["Remote"]), + .library(name: "Intercom", targets: ["Intercom"]), ], targets: [ // Pure logic: models, scenes, settings. @@ -32,8 +33,13 @@ let package = Package( // Control ↔ node over Bonjour/TCP on the technical interface (M3). .target(name: "Remote", dependencies: ["MusubiCore"]), + // Phones on the clinic interface (M4): HTTP signalling + minimal WebRTC + // (ICE-lite, DTLS 1.2, SRTP) built on CryptoKit/CommonCrypto only. + .target(name: "Intercom", dependencies: ["MusubiCore"]), + .testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]), .testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]), .testTarget(name: "RemoteTests", dependencies: ["Remote", "MusubiCore"]), + .testTarget(name: "IntercomTests", dependencies: ["Intercom"]), ] ) diff --git a/Packages/MusubiKit/Sources/Intercom/RTC/Bytes.swift b/Packages/MusubiKit/Sources/Intercom/RTC/Bytes.swift new file mode 100644 index 0000000..cb402aa --- /dev/null +++ b/Packages/MusubiKit/Sources/Intercom/RTC/Bytes.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Big-endian byte writer for the wire formats (STUN, DTLS, RTP). +struct ByteWriter { + private(set) var bytes: [UInt8] = [] + + init(capacity: Int = 64) { bytes.reserveCapacity(capacity) } + + mutating func u8(_ v: UInt8) { bytes.append(v) } + mutating func u16(_ v: UInt16) { bytes += [UInt8(v >> 8), UInt8(v & 0xFF)] } + mutating func u24(_ v: Int) { bytes += [UInt8((v >> 16) & 0xFF), UInt8((v >> 8) & 0xFF), UInt8(v & 0xFF)] } + mutating func u32(_ v: UInt32) { for shift in stride(from: 24, through: 0, by: -8) { bytes.append(UInt8((v >> UInt32(shift)) & 0xFF)) } } + mutating func u48(_ v: UInt64) { for shift in stride(from: 40, through: 0, by: -8) { bytes.append(UInt8((v >> UInt64(shift)) & 0xFF)) } } + mutating func u64(_ v: UInt64) { for shift in stride(from: 56, through: 0, by: -8) { bytes.append(UInt8((v >> UInt64(shift)) & 0xFF)) } } + mutating func append(_ data: C) where C.Element == UInt8 { bytes += data } + + /// Writes `body` prefixed by its length in `lengthBytes` bytes (1, 2 or 3). + mutating func vector(_ lengthBytes: Int, _ body: (inout ByteWriter) -> Void) { + var inner = ByteWriter() + body(&inner) + let n = inner.bytes.count + switch lengthBytes { + case 1: u8(UInt8(n)) + case 2: u16(UInt16(n)) + default: u24(n) + } + bytes += inner.bytes + } +} + +/// Big-endian reader that throws instead of trapping on short input. +struct ByteReader { + struct OutOfBounds: Error {} + + let bytes: [UInt8] + private(set) var offset: Int + + init(_ bytes: C, offset: Int = 0) where C.Element == UInt8 { + self.bytes = Array(bytes) + self.offset = offset + } + + var remaining: Int { bytes.count - offset } + var isAtEnd: Bool { offset >= bytes.count } + + mutating func u8() throws -> UInt8 { + guard remaining >= 1 else { throw OutOfBounds() } + defer { offset += 1 } + return bytes[offset] + } + + mutating func u16() throws -> UInt16 { try UInt16(read(2)) } + mutating func u24() throws -> Int { Int(try read(3)) } + mutating func u32() throws -> UInt32 { try UInt32(read(4)) } + mutating func u48() throws -> UInt64 { try read(6) } + mutating func u64() throws -> UInt64 { try read(8) } + + mutating func take(_ n: Int) throws -> [UInt8] { + guard n >= 0, remaining >= n else { throw OutOfBounds() } + defer { offset += n } + return Array(bytes[offset.. [UInt8] { + let n: Int = switch lengthBytes { + case 1: Int(try u8()) + case 2: Int(try u16()) + default: try u24() + } + return try take(n) + } + + private mutating func read(_ n: Int) throws -> UInt64 { + guard remaining >= n else { throw OutOfBounds() } + var v: UInt64 = 0 + for i in 0.. Bool { + guard a.count == b.count else { return false } + var diff: UInt8 = 0 + for i in a.indices { diff |= a[i] ^ b[i] } + return diff == 0 +} diff --git a/Packages/MusubiKit/Sources/Intercom/RTC/Certificate.swift b/Packages/MusubiKit/Sources/Intercom/RTC/Certificate.swift new file mode 100644 index 0000000..8a9cb17 --- /dev/null +++ b/Packages/MusubiKit/Sources/Intercom/RTC/Certificate.swift @@ -0,0 +1,144 @@ +import CryptoKit +import Foundation + +/// TLS 1.2 PRF with SHA-256 (RFC 5246 section 5). +enum TLSPRF { + static func compute(secret: [UInt8], label: String, seed: [UInt8], length: Int) -> [UInt8] { + let key = SymmetricKey(data: secret) + let fullSeed = Array(label.utf8) + seed + var a = Array(HMAC.authenticationCode(for: fullSeed, using: key)) + var out: [UInt8] = [] + out.reserveCapacity(length + 32) + while out.count < length { + out += HMAC.authenticationCode(for: a + fullSeed, using: key) + a = Array(HMAC.authenticationCode(for: a, using: key)) + } + return Array(out.prefix(length)) + } +} + +/// Minimal DER encoding/decoding for one self-signed ECDSA certificate and for reading peer keys. +enum DER { + static func tlv(_ tag: UInt8, _ content: [UInt8]) -> [UInt8] { + var out = [tag] + let n = content.count + if n < 0x80 { + out.append(UInt8(n)) + } else if n <= 0xFF { + out += [0x81, UInt8(n)] + } else { + out += [0x82, UInt8(n >> 8), UInt8(n & 0xFF)] + } + return out + content + } + + static func sequence(_ items: [UInt8]...) -> [UInt8] { tlv(0x30, items.flatMap { $0 }) } + static func set(_ items: [UInt8]...) -> [UInt8] { tlv(0x31, items.flatMap { $0 }) } + + static func integer(_ bytes: [UInt8]) -> [UInt8] { + var b = Array(bytes.drop { $0 == 0 }) + if b.isEmpty { b = [0] } + if b[0] & 0x80 != 0 { b.insert(0, at: 0) } + return tlv(0x02, b) + } + + static func oid(_ components: [UInt]) -> [UInt8] { + var body = [UInt8(components[0] * 40 + components[1])] + for c in components.dropFirst(2) { + var chunk = [UInt8(c & 0x7F)] + var v = c >> 7 + while v > 0 { + chunk.insert(UInt8(v & 0x7F) | 0x80, at: 0) + v >>= 7 + } + body += chunk + } + return tlv(0x06, body) + } + + static func utf8(_ s: String) -> [UInt8] { tlv(0x0C, Array(s.utf8)) } + static func bitString(_ bytes: [UInt8]) -> [UInt8] { tlv(0x03, [0] + bytes) } + + static func utcTime(_ date: Date) -> [UInt8] { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = "yyMMddHHmmss'Z'" + return tlv(0x17, Array(f.string(from: date).utf8)) + } + + /// One parsed element: tag and the full encoding range inside the source buffer. + struct Element { + let tag: UInt8 + let header: Int + let range: Range + var contentRange: Range { range.lowerBound + header..) -> [Element]? { + var out: [Element] = [] + var i = range.lowerBound + while i < range.upperBound { + guard i + 2 <= range.upperBound else { return nil } + let tag = bytes[i] + var length = Int(bytes[i + 1]) + var header = 2 + if length & 0x80 != 0 { + let count = length & 0x7F + guard count <= 3, i + 2 + count <= range.upperBound else { return nil } + length = 0 + for k in 0.. [UInt8]? { + guard let cert = elements(certificate, in: 0.. start + 5 else { return nil } + return Array(certificate[fields[start + 5].range]) + } +} + +/// The self-signed ECDSA P-256 certificate that identifies this Musubi in DTLS. +/// Browsers only check it against the fingerprint in the SDP, not against a CA. +struct DTLSCertificate: Sendable { + let privateKey: P256.Signing.PrivateKey + let der: [UInt8] + + /// SHA-256 fingerprint as written in SDP (`AB:CD:…`). + var fingerprint: String { Self.fingerprint(der) } + + static func fingerprint(_ der: [UInt8]) -> String { + SHA256.hash(data: der).map { String(format: "%02X", $0) }.joined(separator: ":") + } + + init(commonName: String = "Musubi", now: Date = Date()) throws { + let key = P256.Signing.PrivateKey() + let ecdsaWithSHA256 = DER.sequence(DER.oid([1, 2, 840, 10045, 4, 3, 2])) + let name = DER.sequence(DER.set(DER.sequence(DER.oid([2, 5, 4, 3]), DER.utf8(commonName)))) + let validity = DER.sequence(DER.utcTime(now.addingTimeInterval(-86_400)), DER.utcTime(now.addingTimeInterval(30 * 86_400))) + let spki = DER.sequence( + DER.sequence(DER.oid([1, 2, 840, 10045, 2, 1]), DER.oid([1, 2, 840, 10045, 3, 1, 7])), + DER.bitString(Array(key.publicKey.x963Representation))) + var serial = [UInt8](repeating: 0, count: 8) + _ = SecRandomCopyBytes(kSecRandomDefault, serial.count, &serial) + serial[0] &= 0x7F + let tbs = DER.sequence( + DER.tlv(0xA0, DER.integer([2])), + DER.integer(serial), + ecdsaWithSHA256, name, validity, name, spki) + let signature = try key.signature(for: tbs).derRepresentation + self.privateKey = key + self.der = DER.sequence(tbs, ecdsaWithSHA256, DER.bitString(Array(signature))) + } +} diff --git a/Packages/MusubiKit/Sources/Intercom/RTC/SRTP.swift b/Packages/MusubiKit/Sources/Intercom/RTC/SRTP.swift new file mode 100644 index 0000000..773c1ed --- /dev/null +++ b/Packages/MusubiKit/Sources/Intercom/RTC/SRTP.swift @@ -0,0 +1,139 @@ +import CommonCrypto +import CryptoKit +import Foundation + +/// AES-128 in ECB mode, used as the block function for counter mode (CryptoKit has no AES-CTR). +final class AESBlock: @unchecked Sendable { + private var cryptor: CCCryptorRef? + + init(key: [UInt8]) { + precondition(key.count == 16) + CCCryptorCreate(CCOperation(kCCEncrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionECBMode), + key, key.count, nil, &cryptor) + } + + deinit { if let cryptor { CCCryptorRelease(cryptor) } } + + /// Counter-mode keystream: AES(iv), AES(iv+1), … (counter in the last 16 bits, RFC 3711 4.1.1). + func keystream(iv: [UInt8], count: Int) -> [UInt8] { + let blocks = (count + 15) / 16 + var input = [UInt8](repeating: 0, count: blocks * 16) + for b in 0..> 8) & 0xFF) + block[15] = UInt8(counter & 0xFF) + input.replaceSubrange(b * 16..<(b + 1) * 16, with: block) + } + var output = [UInt8](repeating: 0, count: input.count) + var moved = 0 + CCCryptorUpdate(cryptor, input, input.count, &output, output.count, &moved) + return Array(output.prefix(count)) + } +} + +/// SRTP/SRTCP with the profile SRTP_AES128_CM_HMAC_SHA1_80 (RFC 3711, RFC 5764). +/// One context per direction; this side only sends RTP and reads RTCP feedback. +final class SRTPContext: @unchecked Sendable { + struct SessionKeys { + let cipher: AESBlock + let salt: [UInt8] + let auth: SymmetricKey + } + + static let tagLength = 10 + + let rtp: SessionKeys + let rtcp: SessionKeys + private var rolloverCounters: [UInt32: (roc: UInt32, lastSeq: UInt16)] = [:] + private var srtcpIndex: UInt32 = 0 + + init(masterKey: [UInt8], masterSalt: [UInt8]) { + rtp = Self.derive(masterKey: masterKey, masterSalt: masterSalt, labels: (0, 1, 2)) + rtcp = Self.derive(masterKey: masterKey, masterSalt: masterSalt, labels: (3, 4, 5)) + } + + /// Key derivation (RFC 3711 4.3) with key_derivation_rate 0. + static func deriveKey(masterKey: [UInt8], masterSalt: [UInt8], label: UInt8, length: Int) -> [UInt8] { + var x = masterSalt + [0, 0] + x[7] ^= label + return AESBlock(key: masterKey).keystream(iv: x, count: length) + } + + private static func derive(masterKey: [UInt8], masterSalt: [UInt8], labels: (UInt8, UInt8, UInt8)) -> SessionKeys { + SessionKeys( + cipher: AESBlock(key: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.0, length: 16)), + salt: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.2, length: 14), + auth: SymmetricKey(data: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.1, length: 20))) + } + + /// IV = salt·2^16 ⊕ SSRC·2^64 ⊕ index·2^16. + private static func iv(salt: [UInt8], ssrc: UInt32, index: UInt64) -> [UInt8] { + var iv = salt + [0, 0] + for i in 0..<4 { iv[4 + i] ^= UInt8((ssrc >> UInt32(24 - 8 * i)) & 0xFF) } + for i in 0..<6 { iv[8 + i] ^= UInt8((index >> UInt64(40 - 8 * i)) & 0xFF) } + return iv + } + + /// Encrypts and authenticates one RTP packet. Packets per SSRC must be passed in sending order. + func protectRTP(_ packet: [UInt8]) -> [UInt8] { + guard packet.count >= 12 else { return packet } + let headerLength = Self.rtpHeaderLength(packet) + let seq = UInt16(packet[2]) << 8 | UInt16(packet[3]) + let ssrc = UInt32(packet[8]) << 24 | UInt32(packet[9]) << 16 | UInt32(packet[10]) << 8 | UInt32(packet[11]) + + var state = rolloverCounters[ssrc] ?? (0, seq) + if seq < state.lastSeq && state.lastSeq - seq > 0x8000 { state.roc &+= 1 } + state.lastSeq = seq + rolloverCounters[ssrc] = state + + let index = UInt64(state.roc) << 16 | UInt64(seq) + var out = packet + let payloadCount = packet.count - headerLength + if payloadCount > 0 { + let stream = rtp.cipher.keystream(iv: Self.iv(salt: rtp.salt, ssrc: ssrc, index: index), count: payloadCount) + for i in 0..> 24), UInt8((state.roc >> 16) & 0xFF), UInt8((state.roc >> 8) & 0xFF), UInt8(state.roc & 0xFF)] + let tag = HMAC.authenticationCode(for: out + roc, using: rtp.auth) + return out + Array(tag).prefix(Self.tagLength) + } + + /// Encrypts and authenticates one (compound) RTCP packet. + func protectRTCP(_ packet: [UInt8]) -> [UInt8] { + guard packet.count >= 8 else { return packet } + srtcpIndex = (srtcpIndex + 1) & 0x7FFF_FFFF + let ssrc = UInt32(packet[4]) << 24 | UInt32(packet[5]) << 16 | UInt32(packet[6]) << 8 | UInt32(packet[7]) + var out = packet + let stream = rtcp.cipher.keystream(iv: Self.iv(salt: rtcp.salt, ssrc: ssrc, index: UInt64(srtcpIndex)), count: packet.count - 8) + for i in 0..> 24), UInt8((e >> 16) & 0xFF), UInt8((e >> 8) & 0xFF), UInt8(e & 0xFF)] + let tag = HMAC.authenticationCode(for: out, using: rtcp.auth) + return out + Array(tag).prefix(Self.tagLength) + } + + /// Verifies and decrypts one SRTCP packet; nil if the tag does not match. + func unprotectRTCP(_ packet: [UInt8]) -> [UInt8]? { + guard packet.count >= 8 + 4 + Self.tagLength else { return nil } + let authEnd = packet.count - Self.tagLength + let tag = Array(packet[authEnd...]) + let expected = Array(HMAC.authenticationCode(for: packet[0.. Int { + var length = 12 + Int(packet[0] & 0x0F) * 4 + if packet[0] & 0x10 != 0, packet.count >= length + 4 { + length += 4 + (Int(packet[length + 2]) << 8 | Int(packet[length + 3])) * 4 + } + return min(length, packet.count) + } +} diff --git a/Packages/MusubiKit/Sources/Intercom/RTC/STUN.swift b/Packages/MusubiKit/Sources/Intercom/RTC/STUN.swift new file mode 100644 index 0000000..dff5778 --- /dev/null +++ b/Packages/MusubiKit/Sources/Intercom/RTC/STUN.swift @@ -0,0 +1,141 @@ +import CryptoKit +import Foundation + +/// The subset of STUN (RFC 5389) an ICE-lite agent needs: answer Binding requests +/// with short-term credentials (RFC 8445), MESSAGE-INTEGRITY and FINGERPRINT. +enum STUN { + static let magicCookie: UInt32 = 0x2112_A442 + static let bindingRequest: UInt16 = 0x0001 + static let bindingSuccess: UInt16 = 0x0101 + + enum Attribute: UInt16 { + case username = 0x0006 + case messageIntegrity = 0x0008 + case xorMappedAddress = 0x0020 + case priority = 0x0024 + case useCandidate = 0x0025 + case fingerprint = 0x8028 + case iceControlled = 0x8029 + case iceControlling = 0x802A + } + + struct Message { + var type: UInt16 + var transactionID: [UInt8] + /// Attributes in wire order, with the offset of each attribute header in the packet. + var attributes: [(type: UInt16, value: [UInt8], offset: Int)] + + func value(_ attribute: Attribute) -> [UInt8]? { + attributes.first { $0.type == attribute.rawValue }?.value + } + + var username: String? { value(.username).flatMap { String(bytes: $0, encoding: .utf8) } } + } + + /// RFC 7983 demultiplexing: first byte 0–3 and the magic cookie. + static func looksLikeSTUN(_ packet: [UInt8]) -> Bool { + packet.count >= 20 && packet[0] < 4 + && packet[4] == 0x21 && packet[5] == 0x12 && packet[6] == 0xA4 && packet[7] == 0x42 + } + + static func parse(_ packet: [UInt8]) -> Message? { + guard looksLikeSTUN(packet) else { return nil } + var reader = ByteReader(packet) + do { + let type = try reader.u16() + let length = Int(try reader.u16()) + guard length + 20 == packet.count, length.isMultiple(of: 4) else { return nil } + try reader.skip(4) + let transactionID = try reader.take(12) + var attributes: [(UInt16, [UInt8], Int)] = [] + while !reader.isAtEnd { + let offset = reader.offset + let attrType = try reader.u16() + let attrLength = Int(try reader.u16()) + let value = try reader.take(attrLength) + try reader.skip((4 - attrLength % 4) % 4) + attributes.append((attrType, value, offset)) + } + return Message(type: type, transactionID: transactionID, attributes: attributes) + } catch { + return nil + } + } + + /// Checks MESSAGE-INTEGRITY (HMAC-SHA1 with `key`) and, if present, FINGERPRINT. + static func verify(_ packet: [UInt8], message: Message, key: [UInt8]) -> Bool { + if let fp = message.attributes.first(where: { $0.type == Attribute.fingerprint.rawValue }) { + guard fp.value == fingerprint(packet, upTo: fp.offset) else { return false } + } + guard let mi = message.attributes.first(where: { $0.type == Attribute.messageIntegrity.rawValue }) else { return false } + return constantTimeEqual(mi.value, integrity(packet, upTo: mi.offset, key: key)) + } + + /// Builds a Binding success response with XOR-MAPPED-ADDRESS, MESSAGE-INTEGRITY and FINGERPRINT. + static func bindingResponse(transactionID: [UInt8], address: [UInt8], port: UInt16, key: [UInt8]) -> [UInt8] { + var w = ByteWriter(capacity: 80) + w.u16(bindingSuccess) + w.u16(0) // length, patched below + w.u32(magicCookie) + w.append(transactionID) + + let cookie: [UInt8] = [0x21, 0x12, 0xA4, 0x42] + w.u16(Attribute.xorMappedAddress.rawValue) + w.u16(address.count == 4 ? 8 : 20) + w.u8(0) + w.u8(address.count == 4 ? 0x01 : 0x02) + w.u16(port ^ 0x2112) + let mask = cookie + transactionID + w.append(address.enumerated().map { $0.element ^ mask[$0.offset] }) + + var bytes = w.bytes + let miOffset = bytes.count + let mac = integrity(bytes, upTo: miOffset, key: key) + var tail = ByteWriter() + tail.u16(Attribute.messageIntegrity.rawValue) + tail.u16(20) + tail.append(mac) + bytes += tail.bytes + + let fpOffset = bytes.count + let crc = fingerprint(bytes, upTo: fpOffset) + bytes += [0x80, 0x28, 0x00, 0x04] + crc + setLength(&bytes, bytes.count - 20) + return bytes + } + + /// HMAC-SHA1 over the message up to `offset`, with the header length covering the MESSAGE-INTEGRITY attribute. + private static func integrity(_ packet: [UInt8], upTo offset: Int, key: [UInt8]) -> [UInt8] { + var prefix = Array(packet[0...authenticationCode(for: prefix, using: SymmetricKey(data: key)) + return Array(mac) + } + + /// CRC-32 over the message up to `offset`, length covering FINGERPRINT, XOR 0x5354554E. + private static func fingerprint(_ packet: [UInt8], upTo offset: Int) -> [UInt8] { + var prefix = Array(packet[0..> 24), UInt8((value >> 16) & 0xFF), UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + } + + private static func setLength(_ bytes: inout [UInt8], _ length: Int) { + bytes[2] = UInt8(length >> 8) + bytes[3] = UInt8(length & 0xFF) + } +} + +enum CRC32 { + private static let table: [UInt32] = (0..<256).map { n in + var c = UInt32(n) + for _ in 0..<8 { c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1 } + return c + } + + static func checksum(_ bytes: [UInt8]) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for b in bytes { crc = table[Int((crc ^ UInt32(b)) & 0xFF)] ^ (crc >> 8) } + return crc ^ 0xFFFF_FFFF + } +} diff --git a/Packages/MusubiKit/Tests/IntercomTests/CryptoTests.swift b/Packages/MusubiKit/Tests/IntercomTests/CryptoTests.swift new file mode 100644 index 0000000..42df06e --- /dev/null +++ b/Packages/MusubiKit/Tests/IntercomTests/CryptoTests.swift @@ -0,0 +1,118 @@ +import CryptoKit +import Foundation +import Testing +@testable import Intercom + +@Suite struct STUNTests { + // RFC 5769 2.1: sample request, password "VOkJxbRl1RmTxUk/WvJxBt". + let request = [UInt8](hex: """ + 000100582112a442b7e7a701bc34d686fa87dfae + 802200105354554e207465737420636c69656e74 + 002400046e0001ff + 80290008932ff9b151263b36 + 000600096576746a3a68367659202020 + 000800149aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a2 + 80280004e57a3bcf + """)! + let password = Array("VOkJxbRl1RmTxUk/WvJxBt".utf8) + + @Test func parsesAndVerifiesRFC5769Request() throws { + let message = try #require(STUN.parse(request)) + #expect(message.type == STUN.bindingRequest) + #expect(message.username == "evtj:h6vY") + #expect(STUN.verify(request, message: message, key: password)) + #expect(!STUN.verify(request, message: message, key: Array("wrong".utf8))) + } + + @Test func rejectsTamperedRequest() throws { + var tampered = request + tampered[45] ^= 1 + let message = try #require(STUN.parse(tampered)) + #expect(!STUN.verify(tampered, message: message, key: password)) + } + + @Test func buildsVerifiableResponse() throws { + let tid = Array(request[8..<20]) + let response = STUN.bindingResponse(transactionID: tid, address: [192, 0, 2, 1], port: 32853, key: password) + let message = try #require(STUN.parse(response)) + #expect(message.type == STUN.bindingSuccess) + #expect(message.transactionID == tid) + #expect(STUN.verify(response, message: message, key: password)) + // RFC 5769 2.2: XOR-MAPPED-ADDRESS for 192.0.2.1:32853. + #expect(message.value(.xorMappedAddress) == [UInt8](hex: "0001a147e112a643")) + } + + @Test func demultiplexesByFirstByte() { + #expect(STUN.looksLikeSTUN(request)) + #expect(!STUN.looksLikeSTUN([22, 0xFE, 0xFD] + [UInt8](repeating: 0, count: 30))) + } +} + +@Suite struct KeyDerivationTests { + @Test func tlsPRFMatchesOpenSSL() { + let out = TLSPRF.compute(secret: [UInt8](hex: "9bbe436ba940f017b17652849a71db35")!, label: "test label", + seed: [UInt8](hex: "a0ba9f936cda311827a6f796ffd5198c")!, length: 100) + #expect(out.hex == """ + e3f229ba727be17b8d122620557cd453c2aab21d07c3d495329b52d4e61edb5a6b301791e90d35c9c9a46b4e14baf9af\ + 0fa022f7077def17abfd3797c0564bab4fbc91666e9def9b97fce34f796789baa48082d122ee42c5a72e5a5110fff701\ + 87347b66 + """) + } + + // RFC 3711 B.2 + @Test func aesCounterKeystream() { + let block = AESBlock(key: [UInt8](hex: "2B7E151628AED2A6ABF7158809CF4F3C")!) + let stream = block.keystream(iv: [UInt8](hex: "F0F1F2F3F4F5F6F7F8F9FAFBFCFD0000")!, count: 48) + #expect(stream.hex == "e03ead0935c95e80e166b16dd92b4eb4d23513162b02d0f72a43a2fe4a5f97ab41e95b3bb0a2e8dd477901e4fca894c0") + } + + // RFC 3711 B.3 + @Test func srtpKeyDerivation() { + let key = [UInt8](hex: "E1F97A0D3E018BE0D64FA32C06DE4139")! + let salt = [UInt8](hex: "0EC675AD498AFEEBB6960B3AABE6")! + #expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 0, length: 16).hex == "c61e7a93744f39ee10734afe3ff7a087") + #expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 2, length: 14).hex == "30cbbc08863d8c85d49db34a9ae1") + #expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 1, length: 20).hex == "cebe321f6ff7716b6fd4ab49af256a156d38baa4") + } + + @Test func srtcpRoundTrips() throws { + let sender = SRTPContext(masterKey: Array(repeating: 7, count: 16), masterSalt: Array(repeating: 9, count: 14)) + let receiver = SRTPContext(masterKey: Array(repeating: 7, count: 16), masterSalt: Array(repeating: 9, count: 14)) + // PLI: V=2, FMT=1, PT=206, length 2, sender SSRC, media SSRC + let pli: [UInt8] = [0x81, 206, 0, 2, 1, 2, 3, 4, 5, 6, 7, 8] + let protected = sender.protectRTCP(pli) + #expect(protected != pli) + #expect(receiver.unprotectRTCP(protected) == pli) + var broken = protected + broken[9] ^= 1 + #expect(receiver.unprotectRTCP(broken) == nil) + } + + @Test func srtpEncryptsPayloadOnlyAndAppendsTag() { + let context = SRTPContext(masterKey: Array(repeating: 1, count: 16), masterSalt: Array(repeating: 2, count: 14)) + let packet: [UInt8] = [0x80, 111, 0, 1, 0, 0, 0, 0, 0xAA, 0xBB, 0xCC, 0xDD] + Array(repeating: 0x55, count: 20) + let out = context.protectRTP(packet) + #expect(out.count == packet.count + 10) + #expect(Array(out.prefix(12)) == Array(packet.prefix(12))) + #expect(Array(out[12..<32]) != Array(packet[12...])) + } +} + +@Suite struct CertificateTests { + @Test func selfSignedCertificateCarriesKeyAndValidSignature() throws { + let cert = try DTLSCertificate() + let spki = try #require(DER.subjectPublicKeyInfo(certificate: cert.der)) + let publicKey = try P256.Signing.PublicKey(derRepresentation: spki) + #expect(publicKey.rawRepresentation == cert.privateKey.publicKey.rawRepresentation) + + // Signature over TBSCertificate verifies with the embedded key. + let outer = try #require(DER.elements(cert.der, in: 0..