跳轉到

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.
  • encrypt must 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 and maxSize, 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 -1 and installs nothing.
  • destroy removes frame crypto automatically. Install it again after every initialize.
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 reach decrypt as 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, pass nil for all of them or call Revue.clearFrameCryptor().
  • Your App owns userData. The SDK never retains or releases it. A media thread may still be using a replaced or removed userData, so release it only after destroy has returned.
  • Stay within the capacity. The capacity passed to the encrypt callback is what your getMaxSize callback 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()
    }
}