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 <noreply@anthropic.com>
main
Samuel Zielke 2 days ago
parent 2527b18e8d
commit 0ff5e2f907

@ -12,6 +12,7 @@ let package = Package(
.library(name: "AudioMatrix", targets: ["AudioMatrix"]), .library(name: "AudioMatrix", targets: ["AudioMatrix"]),
.library(name: "DesignSystem", targets: ["DesignSystem"]), .library(name: "DesignSystem", targets: ["DesignSystem"]),
.library(name: "Remote", targets: ["Remote"]), .library(name: "Remote", targets: ["Remote"]),
.library(name: "Intercom", targets: ["Intercom"]),
], ],
targets: [ targets: [
// Pure logic: models, scenes, settings. // Pure logic: models, scenes, settings.
@ -32,8 +33,13 @@ let package = Package(
// Control ↔ node over Bonjour/TCP on the technical interface (M3). // Control ↔ node over Bonjour/TCP on the technical interface (M3).
.target(name: "Remote", dependencies: ["MusubiCore"]), .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: "MusubiCoreTests", dependencies: ["MusubiCore"]),
.testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]), .testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]),
.testTarget(name: "RemoteTests", dependencies: ["Remote", "MusubiCore"]), .testTarget(name: "RemoteTests", dependencies: ["Remote", "MusubiCore"]),
.testTarget(name: "IntercomTests", dependencies: ["Intercom"]),
] ]
) )

@ -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<C: Collection>(_ 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<C: Collection>(_ 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..<offset + n])
}
mutating func skip(_ n: Int) throws { _ = try take(n) }
/// Reads a length-prefixed vector (`lengthBytes` = 1, 2 or 3).
mutating func vector(_ lengthBytes: Int) throws -> [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..<n { v = (v << 8) | UInt64(bytes[offset + i]) }
offset += n
return v
}
}
extension Array where Element == UInt8 {
init?(hex: String) {
let clean = hex.filter { !$0.isWhitespace && $0 != ":" }
guard clean.count.isMultiple(of: 2) else { return nil }
var out: [UInt8] = []
var index = clean.startIndex
while index < clean.endIndex {
let next = clean.index(index, offsetBy: 2)
guard let b = UInt8(clean[index..<next], radix: 16) else { return nil }
out.append(b)
index = next
}
self = out
}
var hex: String { map { String(format: "%02x", $0) }.joined() }
}
/// Compares in constant time (MACs, Finished messages).
func constantTimeEqual(_ a: [UInt8], _ b: [UInt8]) -> 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
}

@ -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<SHA256>.authenticationCode(for: fullSeed, using: key))
var out: [UInt8] = []
out.reserveCapacity(length + 32)
while out.count < length {
out += HMAC<SHA256>.authenticationCode(for: a + fullSeed, using: key)
a = Array(HMAC<SHA256>.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<Int>
var contentRange: Range<Int> { range.lowerBound + header..<range.upperBound }
}
static func elements(_ bytes: [UInt8], in range: Range<Int>) -> [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..<count { length = length << 8 | Int(bytes[i + 2 + k]) }
header += count
}
guard i + header + length <= range.upperBound else { return nil }
out.append(Element(tag: tag, header: header, range: i..<i + header + length))
i += header + length
}
return out
}
/// SubjectPublicKeyInfo of an X.509 certificate in DER.
static func subjectPublicKeyInfo(certificate: [UInt8]) -> [UInt8]? {
guard let cert = elements(certificate, in: 0..<certificate.count)?.first, cert.tag == 0x30,
let tbs = elements(certificate, in: cert.contentRange)?.first, tbs.tag == 0x30,
let fields = elements(certificate, in: tbs.contentRange) else { return nil }
// [0] version (optional), serial, signature, issuer, validity, subject, subjectPublicKeyInfo
let start = fields.first?.tag == 0xA0 ? 1 : 0
guard fields.count > 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)))
}
}

@ -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..<blocks {
var block = iv
let counter = (UInt32(block[14]) << 8 | UInt32(block[15])) &+ UInt32(b)
block[14] = UInt8((counter >> 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..<payloadCount { out[headerLength + i] ^= stream[i] }
}
let roc = [UInt8(state.roc >> 24), UInt8((state.roc >> 16) & 0xFF), UInt8((state.roc >> 8) & 0xFF), UInt8(state.roc & 0xFF)]
let tag = HMAC<Insecure.SHA1>.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..<stream.count { out[8 + i] ^= stream[i] }
let e = srtcpIndex | 0x8000_0000
out += [UInt8(e >> 24), UInt8((e >> 16) & 0xFF), UInt8((e >> 8) & 0xFF), UInt8(e & 0xFF)]
let tag = HMAC<Insecure.SHA1>.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<Insecure.SHA1>.authenticationCode(for: packet[0..<authEnd], using: rtcp.auth)).prefix(Self.tagLength)
guard constantTimeEqual(tag, Array(expected)) else { return nil }
let e = UInt32(packet[authEnd - 4]) << 24 | UInt32(packet[authEnd - 3]) << 16 | UInt32(packet[authEnd - 2]) << 8 | UInt32(packet[authEnd - 1])
var out = Array(packet[0..<authEnd - 4])
guard e & 0x8000_0000 != 0 else { return out }
let ssrc = UInt32(packet[4]) << 24 | UInt32(packet[5]) << 16 | UInt32(packet[6]) << 8 | UInt32(packet[7])
let stream = rtcp.cipher.keystream(iv: Self.iv(salt: rtcp.salt, ssrc: ssrc, index: UInt64(e & 0x7FFF_FFFF)), count: out.count - 8)
for i in 0..<stream.count { out[8 + i] ^= stream[i] }
return out
}
static func rtpHeaderLength(_ packet: [UInt8]) -> 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)
}
}

@ -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..<offset])
setLength(&prefix, offset - 20 + 24)
let mac = HMAC<Insecure.SHA1>.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..<offset])
setLength(&prefix, offset - 20 + 8)
let value = CRC32.checksum(prefix) ^ 0x5354_554E
return [UInt8(value >> 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
}
}

@ -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..<cert.der.count)?.first)
let parts = try #require(DER.elements(cert.der, in: outer.contentRange))
#expect(parts.count == 3)
let tbs = Array(cert.der[parts[0].range])
let signatureBits = Array(cert.der[parts[2].contentRange].dropFirst())
let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureBits)
#expect(publicKey.isValidSignature(signature, for: tbs))
#expect(cert.fingerprint.count == 32 * 3 - 1)
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.