Encryption¶
Frame crypto lets your App encrypt every audio and video frame itself: the SDK hands each encoded frame to your code before sending it, and hands each received frame to your code to decrypt before decoding it. You choose the algorithm and the keys.
This layer sits on top of the connection's own encryption. The connection between two devices is always encrypted, whether or not you install frame crypto; frame crypto adds your own encryption inside it, for example when you must use a specific algorithm or keep frames encrypted with keys only your App knows.
Keys and algorithms¶
The SDK does not manage keys, does not exchange keys and has no built-in algorithm. Your App provides all of it:
- Both devices must use the same algorithm and the same key. Otherwise every frame fails to decrypt.
- To change keys, your App coordinates the change with the other device itself, through its own channel. The SDK does not take part.
Two ways to install¶
FrameCryptor version |
C callback version | |
|---|---|---|
| Install with | Revue.setFrameCryptor(_:) |
Revue.setFrameCrypto(encrypt:decrypt:getMaxSize:userData:) |
| You write | A class that adopts FrameCryptor and works on Data |
Three C callbacks working on the SDK's buffers |
| Memory | The SDK manages it | Zero copy; your App owns userData and its memory |
Gets the stream's ssrc |
No | Yes |
Use the FrameCryptor version unless you need zero copy or the ssrc: the rest of this page describes it,
and C callback version covers the other. Both install the same thing: the one installed
last replaces the other, and Revue.clearFrameCryptor() removes either.
Implement a FrameCryptor¶
A FrameCryptor has three methods: encrypt returns the ciphertext and a counter, decrypt gets the
ciphertext and that counter back on the other device, and maxSize tells the SDK how big a ciphertext can be.
Returning nil from encrypt or decrypt drops that frame (see Errors).
import RevueSDK
import CryptoKit
import Foundation
/// Seals every frame with AES-256-GCM. Both devices must use the same key.
///
/// The SDK calls these methods on its media threads, audio and video possibly at the same time, so the
/// counter is behind a lock. The counter becomes the nonce, which must never repeat for the same key: it
/// starts at a random value so that two devices sharing a key do not reuse each other's nonces.
final class AesGcmFrameCryptor: FrameCryptor {
private static let tagSize = 16
private let key: SymmetricKey
private let lock = NSLock()
private var counter = UInt64.random(in: 0 ... UInt64.max / 2)
init(key: SymmetricKey) {
self.key = key
}
func encrypt(mediaType: FrameMediaType, plaintext: Data) -> (ciphertext: Data, counter: UInt64)? {
let frameCounter = nextCounter()
guard let nonce = Self.nonce(for: frameCounter),
let box = try? AES.GCM.seal(plaintext, using: key, nonce: nonce) else { return nil }
return (box.ciphertext + box.tag, frameCounter)
}
func decrypt(mediaType: FrameMediaType, ciphertext: Data, counter: UInt64) -> Data? {
guard ciphertext.count >= Self.tagSize,
let nonce = Self.nonce(for: counter),
let box = try? AES.GCM.SealedBox(nonce: nonce,
ciphertext: ciphertext.dropLast(Self.tagSize),
tag: ciphertext.suffix(Self.tagSize)) else { return nil }
return try? AES.GCM.open(box, using: key)
}
/// Every frame grows by the 16-byte authentication tag.
func maxSize(mediaType: FrameMediaType, plaintextSize: Int) -> Int {
plaintextSize + Self.tagSize
}
private func nextCounter() -> UInt64 {
lock.lock()
defer { lock.unlock() }
counter &+= 1
return counter
}
/// A 12-byte nonce: four zero bytes, then the counter.
private static func nonce(for counter: UInt64) -> AES.GCM.Nonce? {
let bytes = Data(count: 4) + withUnsafeBytes(of: counter.bigEndian) { Data($0) }
return try? AES.GCM.Nonce(data: bytes)
}
}
Ciphertext size¶
The SDK calls maxSize once for every frame, before encrypt, and its return value is the room the ciphertext
can take. Rules:
- Return the same value for the same input: the same media type and plaintext size.
- For a cipher that keeps the size, such as AES-CTR, return the plaintext size. For AES-GCM, add 16 for the authentication tag.
encryptmust not return a ciphertext bigger than that. A bigger one is not sent: the SDK drops the frame and logs one error with the actual size andmaxSize, the first time it happens after each install.
Counter¶
Your App produces the counter in encrypt, and the SDK sends it as it is to the other device, where decrypt
receives it. The SDK does not check that it increases and does not protect against replayed frames. With an
AEAD cipher such as AES-GCM, the counter usually becomes the nonce: making sure a nonce is never reused with the
same key is up to your App.
Additional authenticated data¶
The FrameCryptor version does not get the stream's ssrc. To bind the ssrc into the additional authenticated
data (AAD) of an AEAD cipher, so that a frame from another stream cannot pass as this one, use the
C callback version, whose callbacks receive it.
Install¶
Call Revue.setFrameCryptor(_:) any time after initialize, including during a call. It returns 0 on
success.
- Before
initialize, it returns-1and installs nothing. destroyremoves frame crypto automatically. Install it again after everyinitialize.
import RevueSDK
import CryptoKit
/// Install after every initialize(_:): destroy() removes it. Calling it again, also during a call,
/// replaces the one installed before. It returns -1 before initialize(_:) and installs nothing.
@MainActor
func installFrameCrypto(key: SymmetricKey) -> Bool {
Revue.shared.setFrameCryptor(AesGcmFrameCryptor(key: key)) == 0
}
Remove and switch¶
Revue.clearFrameCryptor() removes frame crypto; frames are sent and received unencrypted again from then on.
import RevueSDK
/// Stop encrypting during a session. Frames are sent and received as they are from now on,
/// so the other device must remove its cryptor at the same time.
@MainActor
func removeFrameCrypto() {
Revue.shared.clearFrameCryptor()
}
To switch to another key or algorithm, call Revue.setFrameCryptor(_:) again: the new cryptor replaces the old
one. You can switch or remove at any time during a call. The SDK keeps a replaced or removed FrameCryptor
alive until destroy, because a media thread may still be using it for the frame in progress.
Threads¶
The SDK calls your cryptor synchronously on its media threads, not on the main thread. Encryption and decryption of audio and video can run at the same time. Your implementation must:
- Be thread-safe. For example, put the counter behind a lock or make it atomic.
- Not block. Every call holds up the frame it is working on.
- Not call SDK APIs.
Errors¶
Your own callbacks are where you learn whether a frame was encrypted or decrypted: when encrypt or decrypt
returns nil, the SDK drops that frame and the call goes on. When the two devices disagree, frames keep
failing to decrypt, and what the user sees is a frozen picture and no sound.
revue(_:didReceiveFrameCryptoError:) of your RevueDelegate is a diagnostic aid only. It is sent on some media
paths and not others, and it is not guaranteed to arrive: in a one-to-one call it does not arrive at all. When
it does arrive, it is on the main thread. Do not use it to decide whether encryption is working.
import RevueSDK
import Foundation
/// A diagnostic aid only: it is not guaranteed to arrive, so this observer just logs it while you develop.
/// Whether a frame was encrypted or decrypted is decided by your own encrypt and decrypt.
/// The delegate list is weak: keep a reference, and add it again after every initialize(_:).
final class FrameCryptoErrorObserver: RevueDelegate {
@MainActor
func observe() {
Revue.shared.delegates.add(delegate: self)
}
func revue(_ revue: Revue, didReceiveFrameCryptoError error: FrameCryptoError) {
print("frame crypto diagnostic: \(error)")
}
}
| Error | When |
|---|---|
FrameCryptoError.encryptFailed(mediaType:ssrc:code:) |
Encrypting failed: encrypt returned nil or a ciphertext bigger than maxSize, or a C callback returned a non-zero code |
FrameCryptoError.decryptFailed(mediaType:ssrc:code:) |
Decrypting failed, for example with a wrong key |
FrameCryptoError.bufferOversize(mediaType:ssrc:) |
A C callback wrote a ciphertext bigger than the size its getMaxSize callback returned |
An algorithm without authentication, such as XOR, reports nothing when the key is wrong: it "decrypts" to garbage, and the picture and sound break without any error.
When only one side installs¶
The SDK does not negotiate encryption with the other device: it does not tell the other device whether frame crypto is on. When only one side has it installed:
- The receiver has it, the sender does not: frames shorter than 12 bytes are dropped without reaching
decrypt; the rest reachdecryptas plaintext, so an authenticated algorithm fails on them and they are dropped. The picture and sound stop. - The sender has it, the receiver does not: the receiver decodes ciphertext. The picture is corrupted and the sound is noise, and there is no error.
Your App must make sure both devices turn frame crypto on and off together.
C callback version¶
Revue.setFrameCrypto(encrypt:decrypt:getMaxSize:userData:) takes three C callbacks and a userData pointer
that the SDK passes back to every call. The callbacks read and write the SDK's buffers directly, so no frame is
copied, and they receive the stream's ssrc. Everything above about keys, counters, threads and errors applies
here too. In addition:
- Pass all three callbacks together. Passing only some of them fails (it returns
-2) and keeps the current setting. To remove, passnilfor all of them or callRevue.clearFrameCryptor(). - Your App owns
userData. The SDK never retains or releases it. A media thread may still be using a replaced or removeduserData, so release it only afterdestroyhas returned. - Stay within the capacity. The capacity passed to the encrypt callback is what your
getMaxSizecallback returned for that frame. Writing a bigger size drops the frame.
import RevueSDK
import Foundation
/// The C callback version, with XOR so the memory rules stand out. XOR has no authentication: a wrong key
/// is never reported, the picture and sound just break. Use a real algorithm in your App.
///
/// userData is a retained XorState. The App owns it: a replaced one may still be in use on a media
/// thread, so every XorState handed to the SDK is kept until destroy() has returned.
final class XorState {
let key: [UInt8]
private let lock = NSLock()
private var counter: UInt64 = 0
init(key: [UInt8]) {
self.key = key
}
func nextCounter() -> UInt64 {
lock.lock()
defer { lock.unlock() }
counter &+= 1
return counter
}
}
private func xor(_ input: UnsafePointer<UInt8>, into output: UnsafeMutablePointer<UInt8>, count: Int, key: [UInt8]) {
for i in 0 ..< count {
output[i] = input[i] ^ key[i % key.count]
}
}
private let xorEncrypt: FrameEncryptCallback = { _, _, plaintext, size, ciphertext, capacity, outSize, outCounter, userData in
guard let plaintext, let ciphertext, let outSize, let outCounter, let userData, size <= capacity else { return -1 }
let state = Unmanaged<XorState>.fromOpaque(userData).takeUnretainedValue()
xor(plaintext, into: ciphertext, count: size, key: state.key)
outSize.pointee = size
outCounter.pointee = state.nextCounter()
return 0
}
private let xorDecrypt: FrameDecryptCallback = { _, _, _, ciphertext, size, plaintext, capacity, outSize, userData in
guard let ciphertext, let plaintext, let outSize, let userData, size <= capacity else { return -1 }
let state = Unmanaged<XorState>.fromOpaque(userData).takeUnretainedValue()
xor(ciphertext, into: plaintext, count: size, key: state.key)
outSize.pointee = size
return 0
}
/// XOR does not change the size.
private let xorMaxSize: FrameGetMaxSizeCallback = { _, plaintextSize, _ in plaintextSize }
@MainActor
enum XorFrameCrypto {
private static var handedOver: [Unmanaged<XorState>] = []
/// Pass all three callbacks. If the SDK refuses, it never saw this userData, so it can go right away.
static func install(key: [UInt8]) -> Int32 {
let state = Unmanaged.passRetained(XorState(key: key))
let rc = Revue.shared.setFrameCrypto(encrypt: xorEncrypt, decrypt: xorDecrypt,
getMaxSize: xorMaxSize, userData: state.toOpaque())
if rc == 0 {
handedOver.append(state)
} else {
state.release()
}
return rc
}
/// Release the userData values only after destroy() has returned: the media threads have stopped by then.
static func destroyRevue() async {
await Revue.shared.destroy()
handedOver.forEach { $0.release() }
handedOver.removeAll()
}
}