Bluetooth CSIS – Coordinated Set Identification Service

Bluetooth CSIS – Coordinated Set Identification Service
How BLE devices like TWS earbuds recognize each other and work as a team
Spec
CSIS v1.0.1
Standard
BLE / GATT
Level
Intermediate
Stack
BlueZ 5.x

Topics Covered

CSIS SIRK RSI Coordinated Set GATT Service AES-CMAC Set Member Lock Set Member Rank BlueZ TWS Earbuds BLE Advertising

Why Does CSIS Exist?

Imagine you buy a pair of wireless earbuds. Both earbuds are separate Bluetooth devices — each one advertises independently and has its own BLE address. Your phone needs to know that these two devices belong together so it can apply volume changes, call handling, and audio routing to both at the same time.

That is exactly the problem CSIS solves. It gives each member of a device group a shared secret (called the SIRK) so that any client — like your phone — can discover all members of the group even without knowing their exact addresses in advance.

The same idea applies to hearing aids, multi-speaker setups, EKG lead sensor arrays, tyre pressure sensors — any scenario where a group of devices must be treated as one coordinated unit.

Note: CSIS is defined under the Bluetooth LE Audio umbrella but it is transport-agnostic. The SIRK characteristic can also be read over BR/EDR using the Link Key for encryption.

1. Core Concepts

1.1 What is a Coordinated Set?

A Coordinated Set is simply a named group of BLE devices that are configured to work together for a specific job. Examples:

  • Left earbud + Right earbud = one TWS Coordinated Set
  • Left hearing aid + Right hearing aid = one hearing aid Coordinated Set
  • Two stereo speakers that share audio channels = one speaker Coordinated Set
  • Multiple sensor nodes on a patient’s body = one health monitoring Coordinated Set

Each device in the group runs a CSIS server. The phone (or tablet, PC) acts as the CSIS client that discovers and coordinates the group.

Server = Earbud Client = Phone / PC Group = Coordinated Set
1.2 SIRK – The Group Password

The Set Identity Resolving Key (SIRK) is a 128-bit random number that is shared by every device in the same Coordinated Set. Think of it as a group password that is baked in at the factory.

The SIRK has two jobs:

  • Identify the group — devices with the same SIRK belong to the same set.
  • Generate RSIs — each member uses the SIRK to create rotating advertisement tokens (explained next) so that a client who already knows the SIRK can recognise new members in scan results.
Tip: The SIRK is never sent in plain text unless the link is already encrypted. Over LE transport, it is XOR-masked using a key derived from the LTK (Long Term Key).
1.3 RSI – The Rolling Advertisement Token

The Resolvable Set Identifier (RSI) is a 6-byte value that each Coordinated Set member puts in its BLE advertisement. It changes frequently to prevent tracking.

Here is the key insight: Only a client that already has the SIRK can verify that an RSI belongs to that set. It works like a BLE Resolvable Private Address but for group membership instead of individual identity.

RSI structure:

Bits [47:24] – MSB side Bits [23:0] – LSB side
prand (24-bit random number) hash (24-bit AES result)

The two most-significant bits of prand are always 01. The hash is computed using AES-128 with the SIRK as the key and prand (zero-padded to 128 bits) as the plaintext.

2. CSIS Service Structure

CSIS is a standard GATT Primary Service. It contains up to four characteristics. The table below summarises them:

Characteristic Requirement Properties Security Purpose
Set Identity Resolving Key Mandatory Read, (Notify) Encryption required Exposes the group SIRK
Coordinated Set Size Optional Read, (Notify) Encryption required Total number of members in the set
Set Member Lock Optional Read, Write, Notify Encryption required Mutex lock to prevent race conditions
Set Member Rank Mandatory if Lock supported Read Encryption required Ordering within the set (1, 2, 3…)
All characteristics require an encrypted connection. The client must pair and encrypt before reading any of these values.

3. Security Toolbox – How the Math Works

CSIS builds its crypto on top of AES-128. You do not need to implement this yourself in BlueZ — the stack does it — but understanding it helps when you debug or write test code.

3.1 Building Blocks

Encryption function eStandard AES-128 block cipher. Takes a 128-bit key and 128-bit plaintext, gives 128-bit ciphertext.

ciphertext = AES128(key, plaintext)

AES-CMACMessage Authentication Code using AES-128 (RFC 4493). Generates a 128-bit tag from a variable-length message and a 128-bit key.

MAC = AES-CMAC(key, message)
3.2 SIRK Encryption – How the SIRK is Hidden

When a server sends the SIRK over an encrypted link, it does not send the raw SIRK bytes. Instead it XOR-masks them with a derived key so that even if the ATT payload were captured, the raw SIRK stays hidden.

The derivation chain uses two helper functions:

  • s1(M) — SALT generator: runs AES-CMAC on M using a zero key.
  • k1(N, SALT, P) — Key deriver: two-step CMAC. First computes T = AES-CMAC(SALT, N), then returns AES-CMAC(T, P).

The encryption formula for LE transport (K = LTK):

SIRK Encryption Formula (conceptual)pseudo-code
/* Step 1: Compute SALT from the string "SIRKenc" */
SALT = s1("SIRKenc")
     = AES-CMAC(key=0x000...0, msg="SIRKenc")

/* Step 2: Derive mask key using LTK as input */
mask = k1(K=LTK, SALT, P="csis")
     = AES-CMAC(T, "csis")
     where T = AES-CMAC(SALT, LTK)

/* Step 3: XOR mask with raw SIRK */
EncryptedSIRK = mask XOR SIRK

/* Client decrypts the same way (XOR is its own inverse): */
SIRK = mask XOR EncryptedSIRK
Why XOR? XOR with a one-time-use derived key gives perfect confidentiality (like a one-time-pad) as long as the same mask is never reused. Because the LTK changes every pairing, the mask changes too.
3.3 RSI Generation – Step by Step
1
Generate a 24-bit random number prand. Force the two MSBs to 01 (bits 23:22 = 0, 1). At least one other bit must be 0, and at least one must be 1.
2
Pad prand with 104 zero bits on the MSB side to make it 128 bits: r' = 0x000...0 || prand
3
Run AES-128 with the SIRK as the key and r’ as the plaintext: result = AES128(SIRK, r')
4
Take only the lowest 24 bits of result — that is the hash.
5
Concatenate: RSI = prand || hash (prand in upper 3 bytes, hash in lower 3 bytes). Total = 6 bytes.
rsi_generate.c – RSI generation helperC / BlueZ style
#include <stdint.h>
#include <string.h>
#include "src/shared/crypto.h"   /* bt_crypto_aes_cmac, bt_crypto_e */

/*
 * rsi_generate - produce a 6-byte RSI from a 128-bit SIRK
 *
 * @sirk : 16-byte SIRK (little-endian as stored internally)
 * @rsi  : output buffer, must be 6 bytes
 *
 * Returns 0 on success, -1 on failure.
 */
int rsi_generate(const uint8_t sirk[16], uint8_t rsi[6])
{
    uint8_t prand[3];
    uint8_t r_prime[16];  /* 128-bit padded prand */
    uint8_t aes_out[16];  /* AES-128 output       */
    uint32_t raw;

    /* Step 1: get 24 bits of randomness */
    if (getrandom(&raw, 3, 0) < 0)
        return -1;

    raw &= 0x00FFFFFFu;        /* keep only 24 bits           */
    raw &= ~(1u << 23);        /* bit 23 = 0                  */
    raw |=  (1u << 22);        /* bit 22 = 1  -> MSBs = 0b01  */

    /* ensure at least one 0 and one 1 in the random part [21:0] */
    if ((raw & 0x3FFFFFu) == 0x000000u) raw |= 0x000001u;
    if ((raw & 0x3FFFFFu) == 0x3FFFFFu) raw &= ~0x000001u;

    prand[0] = (raw >> 0)  & 0xFF;  /* LSB */
    prand[1] = (raw >> 8)  & 0xFF;
    prand[2] = (raw >> 16) & 0xFF;  /* MSB */

    /* Step 2: build r' = 0x0000...0000 || prand (128 bits, LE) */
    memset(r_prime, 0, sizeof(r_prime));
    r_prime[0] = prand[0];
    r_prime[1] = prand[1];
    r_prime[2] = prand[2];
    /* bytes [3..15] remain 0 */

    /*
     * Step 3: AES-128 encrypt r' with SIRK as key.
     * bt_crypto_e() matches the spec function e(key, plaintext).
     * Both key and plaintext are passed little-endian.
     */
    if (!bt_crypto_e(sirk, r_prime, aes_out))
        return -1;

    /*
     * Step 4+5: hash = lowest 24 bits of aes_out (bytes [0..2] in LE)
     * RSI layout:  byte[0..2] = hash,  byte[3..5] = prand
     */
    rsi[0] = aes_out[0];   /* hash LSB */
    rsi[1] = aes_out[1];
    rsi[2] = aes_out[2];   /* hash MSB */
    rsi[3] = prand[0];     /* prand LSB */
    rsi[4] = prand[1];
    rsi[5] = prand[2];     /* prand MSB */

    return 0;
}
rsi_resolve.c – RSI resolution (does this RSI belong to our set?)C / BlueZ style
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include "src/shared/crypto.h"

/*
 * rsi_resolve - check if an RSI was generated by a device with the given SIRK
 *
 * @sirk : known 16-byte SIRK (little-endian)
 * @rsi  : 6-byte RSI from advertisement data
 *
 * Returns true if the RSI resolves against this SIRK.
 */
bool rsi_resolve(const uint8_t sirk[16], const uint8_t rsi[6])
{
    uint8_t r_prime[16];
    uint8_t aes_out[16];
    uint8_t local_hash[3];

    /* Split: bytes[3..5] = prand, bytes[0..2] = hash from advertisement */
    /* r' = zero-padded prand */
    memset(r_prime, 0, sizeof(r_prime));
    r_prime[0] = rsi[3];   /* prand LSB */
    r_prime[1] = rsi[4];
    r_prime[2] = rsi[5];   /* prand MSB */

    /* Compute local_hash = AES128(SIRK, r') truncated to 24 bits */
    if (!bt_crypto_e(sirk, r_prime, aes_out))
        return false;

    local_hash[0] = aes_out[0];
    local_hash[1] = aes_out[1];
    local_hash[2] = aes_out[2];

    /* Compare computed hash with the hash embedded in the RSI */
    return (local_hash[0] == rsi[0] &&
            local_hash[1] == rsi[1] &&
            local_hash[2] == rsi[2]);
}

4. Characteristics Deep Dive

4.1 Set Identity Resolving Key (SIRK Characteristic)

This is the only mandatory characteristic. It carries the SIRK in a 17-byte payload:

Field Size Values
Type 1 byte (uint8) 0x00 = Encrypted SIRK  |  0x01 = Plain-text SIRK
Value 16 bytes The SIRK (encrypted or plain-text)

If the server sets Type = 0x00, the 16 Value bytes are the output of the sef (SIRK encryption function). The client must run the sdf (SIRK decryption function) using its LTK to get the real SIRK back.

If the server only wants to share the SIRK out-of-band (e.g. via NFC during manufacturing), it returns error code 0x83 OOB SIRK Only in response to any read attempt.

csis_sirk_char.c – BlueZ GATT characteristic read callback patternC / BlueZ GATT DB
#include "src/shared/gatt-db.h"
#include "src/shared/crypto.h"
#include <string.h>

#define CSIS_SIRK_TYPE_ENCRYPTED  0x00
#define CSIS_SIRK_TYPE_PLAINTEXT  0x01
#define CSIS_ERR_OOB_SIRK_ONLY   0x83

struct csis_server {
    uint8_t  sirk[16];      /* raw 128-bit SIRK, stored securely    */
    bool     sirk_oob_only; /* if true, reject all read requests    */
    bool     sirk_encrypted;/* if true, encrypt before sending      */
};

/*
 * sirk_read_cb - called by GATT DB when a client reads the SIRK characteristic
 */
static void sirk_read_cb(struct gatt_db_attribute *attr,
                          unsigned int id,
                          uint16_t offset,
                          uint8_t opcode,
                          struct bt_att *att,
                          void *user_data)
{
    struct csis_server *cs = user_data;
    uint8_t ltk[16];
    uint8_t enc_sirk[16];
    uint8_t response[17];  /* 1 byte type + 16 bytes value */

    /* If this server only shares SIRK out-of-band, reject the read */
    if (cs->sirk_oob_only) {
        gatt_db_attribute_read_result(attr, id,
                                      CSIS_ERR_OOB_SIRK_ONLY,
                                      NULL, 0);
        return;
    }

    if (cs->sirk_encrypted) {
        /*
         * Retrieve the LTK shared with this client.
         * In BlueZ this comes from the btd_device bond info.
         * Here we show the concept only.
         */
        get_ltk_for_client(att, ltk);   /* implementation-specific */

        /* Encrypt: EncSIRK = sef(LTK, SIRK) */
        csis_sef(ltk, cs->sirk, enc_sirk);

        response[0] = CSIS_SIRK_TYPE_ENCRYPTED;
        memcpy(&response[1], enc_sirk, 16);
    } else {
        response[0] = CSIS_SIRK_TYPE_PLAINTEXT;
        memcpy(&response[1], cs->sirk, 16);
    }

    gatt_db_attribute_read_result(attr, id, 0, response, sizeof(response));
}

/*
 * csis_sef - SIRK encryption function
 * sef(K, SIRK) = k1(K, s1("SIRKenc"), "csis") XOR SIRK
 */
int csis_sef(const uint8_t K[16],
             const uint8_t sirk[16],
             uint8_t enc_sirk[16])
{
    static const uint8_t sirkenc_str[] = "SIRKenc";
    static const uint8_t csis_str[]    = "csis";
    uint8_t salt[16];
    uint8_t mask[16];
    int i;

    /* s1("SIRKenc") = AES-CMAC(key=zeros, msg="SIRKenc") */
    if (!bt_crypto_aes_cmac_zero(sirkenc_str,
                                  sizeof(sirkenc_str) - 1, salt))
        return -1;

    /* k1(K, salt, "csis") -> mask */
    if (!bt_crypto_k1(K, salt, csis_str,
                       sizeof(csis_str) - 1, mask))
        return -1;

    /* EncSIRK = mask XOR SIRK */
    for (i = 0; i < 16; i++)
        enc_sirk[i] = mask[i] ^ sirk[i];

    return 0;
}
4.2 Coordinated Set Size

A single uint8 that tells the client how many devices are in this Coordinated Set. Valid range is 1–255. The value 0x00 is Prohibited (you cannot have a set of zero devices).

This characteristic is optional. Some implementations omit it, and the client is expected to discover the set size by finding all members on its own. But when present, it helps the client know when it has found all members — it keeps scanning until its member count equals this value.

csis_set_size.c – registering the Coordinated Set Size characteristicC / BlueZ GATT DB
#include "src/shared/gatt-db.h"

#define BT_UUID_CSIS_SET_SIZE  0x2B85   /* GATT assigned number */

/*
 * Register Coordinated Set Size as a readable GATT characteristic.
 * set_size: number of members in this Coordinated Set (e.g., 2 for TWS earbuds)
 */
void csis_register_set_size(struct gatt_db *db,
                              struct gatt_db_attribute *service,
                              uint8_t set_size)
{
    bt_uuid_t uuid;
    struct gatt_db_attribute *attr;

    bt_uuid16_create(&uuid, BT_UUID_CSIS_SET_SIZE);

    attr = gatt_db_service_add_characteristic(
                service,
                &uuid,
                BT_ATT_PERM_READ_ENCRYPT,    /* encrypted link required */
                BT_GATT_CHRC_PROP_READ,
                NULL,    /* no write callback */
                NULL,    /* user_destroy */
                NULL);   /* user_data – fill in for dynamic reads */

    /*
     * For a static value, we can store it directly in the db.
     * gatt_db_attribute_write() sets the static value buffer.
     */
    gatt_db_attribute_write(attr, 0, &set_size, sizeof(set_size),
                             BT_ATT_OP_WRITE_REQ, NULL,
                             NULL, NULL);
}
4.3 Set Member Lock – The Mutex Characteristic

This is the most interesting characteristic from an implementation standpoint. Imagine this scenario:

  • Two phone apps are both connected to your left and right earbuds simultaneously.
  • App A wants to change the EQ setting. It starts writing to the left earbud.
  • App B also starts writing to the right earbud at the same time.

The earbuds can end up in inconsistent states. Set Member Lock prevents this by letting a client lock all members before making changes, similar to a mutex in multi-threaded programming.

Value (uint8) Meaning
0x01 Unlocked – any client can request the lock
0x02 Locked – only the client holding the lock can write
Others RFU – server must reject with error 0x82 (Invalid Lock Value)

Error codes the server can return for lock operations:

Error Code Value When returned
Lock Denied 0x80 Another client already holds the lock
Lock Release Not Allowed 0x81 You are trying to unlock but you do not own the lock
Invalid Lock Value 0x82 You wrote a value other than 0x01 or 0x02
Lock Already Granted 0x84 You already hold the lock — no duplicate grant
Lock Timeout: The lock auto-releases after a timeout (default 60 seconds) to prevent a crashed client from holding the set locked forever. If the server and client are not bonded and the connection drops while locked, the server immediately unlocks.
csis_lock.c – Set Member Lock write handlerC / BlueZ GATT DB
#include "src/shared/gatt-db.h"
#include "src/shared/timeout.h"
#include <stdint.h>
#include <stdbool.h>

#define CSIS_LOCK_UNLOCKED    0x01
#define CSIS_LOCK_LOCKED      0x02

#define CSIS_ERR_LOCK_DENIED          0x80
#define CSIS_ERR_LOCK_RELEASE_NA      0x81
#define CSIS_ERR_INVALID_LOCK_VALUE   0x82
#define CSIS_ERR_LOCK_ALREADY_GRANTED 0x84

#define LOCK_TIMEOUT_MS  60000   /* 60 seconds default */

struct csis_lock_state {
    uint8_t  value;              /* current lock state: 0x01 or 0x02 */
    void    *owner_att;          /* bt_att handle of locking client   */
    unsigned int timeout_id;     /* timeout handle, 0 if not running  */
};

static struct csis_lock_state g_lock = {
    .value      = CSIS_LOCK_UNLOCKED,
    .owner_att  = NULL,
    .timeout_id = 0,
};

/* Forward declaration */
static void lock_timeout_cb(void *user_data);

static void lock_write_cb(struct gatt_db_attribute *attr,
                           unsigned int id,
                           uint16_t offset,
                           const uint8_t *value,
                           size_t len,
                           uint8_t opcode,
                           struct bt_att *att,
                           void *user_data)
{
    uint8_t requested;

    if (len != 1) {
        gatt_db_attribute_write_result(attr, id, BT_ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LEN);
        return;
    }

    requested = value[0];

    /* Reject any value that is not 0x01 or 0x02 */
    if (requested != CSIS_LOCK_UNLOCKED && requested != CSIS_LOCK_LOCKED) {
        gatt_db_attribute_write_result(attr, id, CSIS_ERR_INVALID_LOCK_VALUE);
        return;
    }

    /* ── LOCK REQUEST (client wants to acquire) ── */
    if (requested == CSIS_LOCK_LOCKED) {

        if (g_lock.value == CSIS_LOCK_UNLOCKED) {
            /* Grant the lock */
            g_lock.value     = CSIS_LOCK_LOCKED;
            g_lock.owner_att = att;
            g_lock.timeout_id = timeout_add(LOCK_TIMEOUT_MS,
                                             lock_timeout_cb, NULL, NULL);
            gatt_db_attribute_write_result(attr, id, 0);  /* success */

        } else if (g_lock.owner_att == att) {
            /* This client already owns the lock */
            gatt_db_attribute_write_result(attr, id, CSIS_ERR_LOCK_ALREADY_GRANTED);

        } else {
            /* Another client holds the lock */
            gatt_db_attribute_write_result(attr, id, CSIS_ERR_LOCK_DENIED);
        }
        return;
    }

    /* ── UNLOCK REQUEST (client wants to release) ── */
    if (requested == CSIS_LOCK_UNLOCKED) {

        if (g_lock.value == CSIS_LOCK_UNLOCKED) {
            /* Already unlocked – write succeeds silently */
            gatt_db_attribute_write_result(attr, id, 0);

        } else if (g_lock.owner_att == att) {
            /* Legitimate release by the lock owner */
            g_lock.value     = CSIS_LOCK_UNLOCKED;
            g_lock.owner_att = NULL;
            timeout_remove(g_lock.timeout_id);
            g_lock.timeout_id = 0;
            gatt_db_attribute_write_result(attr, id, 0);
            /* TODO: notify other clients via CCCD */

        } else {
            /* Different client trying to steal the unlock */
            gatt_db_attribute_write_result(attr, id, CSIS_ERR_LOCK_RELEASE_NA);
        }
    }
}

static void lock_timeout_cb(void *user_data)
{
    /* Auto-release after 60 s to avoid a crashed client blocking the set */
    g_lock.value      = CSIS_LOCK_UNLOCKED;
    g_lock.owner_att  = NULL;
    g_lock.timeout_id = 0;
    /* TODO: send GATT notification to all subscribed clients */
}
4.4 Set Member Rank

Each member of a Coordinated Set has a unique rank — an integer starting from 1 that goes up to the set size. For a TWS pair, the left earbud might be rank 1 and the right rank 2.

This lets a client iterate through members in a predictable order when applying sequential operations (e.g., update firmware one member at a time, starting from rank 1).

Key rule: Rank values across all members in the same set must form a contiguous sequence: 1, 2, 3 … N where N = Coordinated Set Size. No gaps, no duplicates.
csis_rank.c – registering Set Member Rank as a static readable characteristicC / BlueZ GATT DB
#include "src/shared/gatt-db.h"

#define BT_UUID_CSIS_MEMBER_RANK  0x2B89   /* GATT assigned number */

/*
 * Register Set Member Rank characteristic.
 *
 * @rank: rank of this device within the Coordinated Set.
 *        Must be in range [1 .. Coordinated Set Size].
 */
void csis_register_member_rank(struct gatt_db *db,
                                struct gatt_db_attribute *service,
                                uint8_t rank)
{
    bt_uuid_t uuid;
    struct gatt_db_attribute *attr;

    /* Rank must start from 1 */
    if (rank == 0) {
        error("CSIS: rank 0 is not allowed");
        return;
    }

    bt_uuid16_create(&uuid, BT_UUID_CSIS_MEMBER_RANK);

    attr = gatt_db_service_add_characteristic(
                service,
                &uuid,
                BT_ATT_PERM_READ_ENCRYPT,
                BT_GATT_CHRC_PROP_READ,
                NULL, NULL, NULL);

    gatt_db_attribute_write(attr, 0,
                             &rank, sizeof(rank),
                             BT_ATT_OP_WRITE_REQ,
                             NULL, NULL, NULL);
}

5. End-to-End Discovery Flow

Now that all the pieces are clear, here is the complete flow from a client’s perspective when it first discovers a pair of TWS earbuds:

1
Scan & Connect to any one member. The client picks up Left Earbud’s advertisement (identified by name, appearance, or audio service UUIDs). It connects and pairs.
2
Discover CSIS service. The client runs GATT discovery and finds the Primary Service with UUID = «Coordinated Set Identification Service».
3
Read SIRK characteristic. The client reads the 17-byte SIRK value. If Type = 0x00, it decrypts using sdf(LTK, EncSIRK). Now it has the raw 128-bit SIRK.
4
Read Set Size. Client reads the Coordinated Set Size (e.g., 2). It now knows it must find exactly one more member.
5
Start RSI-aware scan. The client scans for other BLE advertisements that carry an RSI AD type. For every RSI found, it runs rsi_resolve(SIRK, rsi). If it returns true, that device is the Right Earbud — a member of the same set.
6
Connect to remaining members. The client connects to the Right Earbud, verifies CSIS is present, and confirms the SIRK matches. The Coordinated Set is now fully discovered.
7
Lock → Operate → Unlock. Before making any changes, the client writes 0x02 (Locked) to Set Member Lock on every member. It applies its operation (volume change, codec config, etc.), then writes 0x01 (Unlocked) on all members to release.

6. BlueZ CSIS in Practice

6.1 Where to Find CSIS in the BlueZ Source Tree

CSIS lives in the audio profiles directory. Key files:

File Role
profiles/audio/csis.c CSIS server implementation — GATT service, characteristics, lock logic
profiles/audio/csis.h Public API for CSIS server module
client/csis.c CSIS client — connects, reads SIRK, resolves RSIs
src/shared/crypto.c bt_crypto_e, bt_crypto_aes_cmac, bt_crypto_k1 primitives
src/shared/ad.c Advertisement data parser — reads RSI AD type from scan results
6.2 Registering the Full CSIS Server Service
csis_server_init.c – wiring up the complete CSIS GATT serviceC / BlueZ GATT DB
#include "src/shared/gatt-db.h"
#include "src/shared/crypto.h"
#include <string.h>
#include <stdint.h>

/* GATT UUIDs (from Bluetooth Assigned Numbers) */
#define BT_UUID_CSIS            0x1846
#define BT_UUID_CSIS_SIRK       0x2B84
#define BT_UUID_CSIS_SET_SIZE   0x2B85
#define BT_UUID_CSIS_LOCK       0x2B86
#define BT_UUID_CSIS_RANK       0x2B89

struct csis_config {
    uint8_t  sirk[16];          /* 128-bit SIRK                       */
    bool     encrypt_sirk;      /* true = send encrypted form         */
    uint8_t  set_size;          /* total members in this set          */
    uint8_t  rank;              /* rank of THIS device (1-based)      */
    bool     support_lock;      /* true = add Set Member Lock char    */
};

/*
 * csis_server_init - creates and registers the CSIS Primary Service
 * Returns the service attribute, or NULL on failure.
 */
struct gatt_db_attribute *csis_server_init(struct gatt_db *db,
                                            const struct csis_config *cfg)
{
    bt_uuid_t svc_uuid, char_uuid;
    struct gatt_db_attribute *service;
    bool primary = true;

    /* ── 1. Create Primary Service ── */
    bt_uuid16_create(&svc_uuid, BT_UUID_CSIS);
    service = gatt_db_add_service(db, &svc_uuid, primary, 20);
    if (!service)
        return NULL;

    /* ── 2. Set Identity Resolving Key (Mandatory) ── */
    bt_uuid16_create(&char_uuid, BT_UUID_CSIS_SIRK);
    gatt_db_service_add_characteristic(service, &char_uuid,
            BT_ATT_PERM_READ_ENCRYPT,
            BT_GATT_CHRC_PROP_READ | BT_GATT_CHRC_PROP_NOTIFY,
            sirk_read_cb,       /* defined in section 4.1 above */
            NULL,               /* write cb – not needed        */
            (void *)cfg);       /* pass config as user_data     */

    /* ── 3. Coordinated Set Size (Optional) ── */
    if (cfg->set_size > 0) {
        bt_uuid16_create(&char_uuid, BT_UUID_CSIS_SET_SIZE);
        struct gatt_db_attribute *sz_attr;
        sz_attr = gatt_db_service_add_characteristic(service, &char_uuid,
                BT_ATT_PERM_READ_ENCRYPT,
                BT_GATT_CHRC_PROP_READ,
                NULL, NULL, NULL);
        gatt_db_attribute_write(sz_attr, 0,
                                 &cfg->set_size, 1,
                                 BT_ATT_OP_WRITE_REQ,
                                 NULL, NULL, NULL);
    }

    /* ── 4. Set Member Lock (Optional) ── */
    if (cfg->support_lock) {
        bt_uuid16_create(&char_uuid, BT_UUID_CSIS_LOCK);
        gatt_db_service_add_characteristic(service, &char_uuid,
                BT_ATT_PERM_READ_ENCRYPT | BT_ATT_PERM_WRITE_ENCRYPT,
                BT_GATT_CHRC_PROP_READ | BT_GATT_CHRC_PROP_WRITE |
                BT_GATT_CHRC_PROP_NOTIFY,
                lock_read_cb,   /* returns current lock state   */
                lock_write_cb,  /* defined in section 4.3 above */
                NULL);

        /* ── 5. Set Member Rank – Mandatory when Lock is supported ── */
        bt_uuid16_create(&char_uuid, BT_UUID_CSIS_RANK);
        struct gatt_db_attribute *rank_attr;
        rank_attr = gatt_db_service_add_characteristic(service, &char_uuid,
                BT_ATT_PERM_READ_ENCRYPT,
                BT_GATT_CHRC_PROP_READ,
                NULL, NULL, NULL);
        gatt_db_attribute_write(rank_attr, 0,
                                 &cfg->rank, 1,
                                 BT_ATT_OP_WRITE_REQ,
                                 NULL, NULL, NULL);
    }

    gatt_db_service_set_active(service, true);
    return service;
}

/* ── Example usage: TWS Left Earbud ── */
void setup_left_earbud_csis(struct gatt_db *db)
{
    struct csis_config cfg;

    /* All members of the same TWS set must share this exact SIRK */
    static const uint8_t tws_sirk[16] = {
        0x45, 0x7d, 0x7d, 0x09, 0x21, 0xa1, 0xfd, 0x22,
        0xce, 0xcd, 0x8c, 0x86, 0xdd, 0x72, 0xcc, 0xcd
    };

    memcpy(cfg.sirk, tws_sirk, 16);
    cfg.encrypt_sirk = true;   /* always encrypt over LE   */
    cfg.set_size     = 2;      /* 2 earbuds in this set    */
    cfg.rank         = 1;      /* left = rank 1            */
    cfg.support_lock = true;

    csis_server_init(db, &cfg);
}
6.3 Adding RSI to BLE Advertisements

To be discoverable as a set member, each device must include the RSI AD type in its advertisement. The RSI must be refreshed periodically (BlueZ refreshes it when the advertising set is restarted or on a timer).

csis_adv.c – adding RSI to a BLE advertisement using bt_adC / BlueZ bt_ad API
#include "src/shared/ad.h"

#define AD_TYPE_RSI  0x2E   /* RSI AD type value from Assigned Numbers */

/*
 * csis_add_rsi_to_adv - appends a freshly generated RSI to an advertisement
 *
 * @ad   : existing advertisement data structure to append to
 * @sirk : 16-byte SIRK of this device's Coordinated Set
 */
int csis_add_rsi_to_adv(struct bt_ad *ad, const uint8_t sirk[16])
{
    uint8_t rsi[6];

    /* Generate a fresh RSI using the helper from Section 3.3 */
    if (rsi_generate(sirk, rsi) < 0)
        return -1;

    /*
     * bt_ad_add_service_data is reused here for arbitrary AD types.
     * BlueZ provides bt_ad_add_data() for raw AD type insertion.
     */
    if (!bt_ad_add_data(ad, AD_TYPE_RSI, rsi, sizeof(rsi)))
        return -1;

    return 0;
}

/*
 * Example: build a complete advertisement for a CSIS set member
 */
void build_csis_member_adv(const uint8_t sirk[16], const char *local_name)
{
    struct bt_ad *adv = bt_ad_new();

    /* Device name */
    bt_ad_add_name(adv, local_name);

    /* LE Audio / CSIS service UUIDs so clients know to look for CSIS */
    bt_uuid_t csis_uuid;
    bt_uuid16_create(&csis_uuid, 0x1846);
    bt_ad_add_service_uuid(adv, &csis_uuid);

    /* RSI – lets clients holding the SIRK identify this as a set member */
    csis_add_rsi_to_adv(adv, sirk);

    /* Hand adv to the mgmt / HCI advertising APIs */
    start_le_advertising(adv);   /* platform-specific */

    bt_ad_unref(adv);
}
6.4 Client Side – Scanning and Resolving RSIs
csis_client_scan.c – resolving RSIs during active scanC / BlueZ bt_hci / mainloop
#include "src/shared/hci.h"
#include "src/shared/ad.h"
#include <stdbool.h>
#include <stdio.h>

#define AD_TYPE_RSI  0x2E

/* Context held by the CSIS client */
struct csis_client_ctx {
    uint8_t  sirk[16];          /* SIRK learned from first member   */
    uint8_t  set_size;          /* total members expected           */
    uint8_t  found_count;       /* how many we have found so far    */
};

/*
 * on_scan_result_cb - called for every advertisement packet received
 */
static void on_scan_result_cb(const bdaddr_t *addr,
                               uint8_t addr_type,
                               const uint8_t *adv_data,
                               uint8_t adv_data_len,
                               void *user_data)
{
    struct csis_client_ctx *ctx = user_data;
    struct bt_ad *ad;
    uint8_t rsi[6];
    size_t rsi_len = sizeof(rsi);
    char addr_str[18];

    /* Already found the whole set */
    if (ctx->found_count >= ctx->set_size)
        return;

    /* Parse the advertisement */
    ad = bt_ad_new_with_data(adv_data, adv_data_len);
    if (!ad)
        return;

    /* Look for RSI AD type (0x2E) in this advertisement */
    if (!bt_ad_get_data(ad, AD_TYPE_RSI, rsi, &rsi_len) || rsi_len != 6)
        goto done;

    /* Try to resolve the RSI against our known SIRK */
    if (!rsi_resolve(ctx->sirk, rsi))
        goto done;   /* RSI does not belong to our set */

    /* Match! This device is a member of our Coordinated Set */
    ba2str(addr, addr_str);
    printf("[CSIS] Found set member: %s (member %u of %u)\n",
           addr_str,
           ctx->found_count + 1,
           ctx->set_size);

    ctx->found_count++;

    if (ctx->found_count == ctx->set_size) {
        printf("[CSIS] All %u members discovered. Stopping scan.\n",
               ctx->set_size);
        stop_le_scan();   /* platform-specific */
    }

done:
    bt_ad_unref(ad);
}

7. Common Pitfalls for Students

Pitfall 1 – Reading SIRK Unencrypted

If you try to read the SIRK characteristic before pairing (i.e., without an encrypted link), the server will return ATT error 0x0F Insufficient Encryption. Always pair first.

Pitfall 2 – Mismatched SIRK Byte Order

The GATT characteristic transmits bytes LSO-first (little-endian), but AES-CMAC and the e() function expect bytes in a specific order. The sample data in Appendix A of the spec uses MSO-first. Check your byte ordering before doing crypto.

Pitfall 3 – Stale RSI After SIRK Update

If the SIRK changes (provisioned OOB), all running advertisements must regenerate their RSIs immediately. Stale RSIs generated from the old SIRK will not resolve and the client will lose visibility of the set members.

Pitfall 4 – Forgetting Set Member Rank = 0 is Prohibited

Rank values must start from 1. Writing rank = 0 means “no rank” which is invalid. If you see rank 0 in your device firmware, the client may misbehave when ordering operations.

8. Quick Reference Cheat Sheet

Item Value / Detail
Service UUID 0x1846 (Coordinated Set Identification Service)
SIRK UUID 0x2B84
Set Size UUID 0x2B85
Set Member Lock UUID 0x2B86
Set Member Rank UUID 0x2B89
RSI AD Type 0x2E
RSI Size 6 bytes (3 prand + 3 hash)
SIRK Size 16 bytes (128 bits)
SIRK Type: Encrypted 0x00
SIRK Type: Plain text 0x01
Lock: Unlocked 0x01
Lock: Locked 0x02
Lock Timeout (default) 60 seconds
Rank range 0x01 to Coordinated Set Size
Crypto primitive AES-128 + AES-CMAC (RFC 4493)
BT Core compatibility v4.2 and later

Continue the BLE Audio Journey

CSIS is the foundation. Next up in the series — PACS (Published Audio Capabilities Service) and ASCS (Audio Stream Control Service) that build on top of Coordinated Sets to deliver actual audio.

Next: PACS Deep Dive → Back to BLE Series Index

Leave a Reply

Your email address will not be published. Required fields are marked *