matter_case.h
proving an operational identity, both ways.
Overview
proving an operational identity, both ways. PASE let a commissioner in because it knew a printed code. CASE is what happens afterwards, every time: two nodes that already hold certificates from the same fabric prove it to each other and agree on session keys. It is the only session type the spec will accept CommissioningComplete over, and the only way a phone talks to this node once BLE is gone. Sigma1 initiator -> responder who I want, and my ephemeral key Sigma2 responder -> initiator my certificate chain, signed, encrypted Sigma3 initiator -> responder the same, in the other direction This file is the responder's half, built in that order. The subtle piece is Sigma1's destinationId. It is not an address: it is an HMAC that only somebody holding the fabric's identity protection key could have produced, over the identity they are asking for. A responder does not read a node id out of it -- it recomputes the HMAC for each fabric it holds and looks for a match. That is what makes an unsolicited Sigma1 unable to enumerate a node's fabrics: get the key wrong and you learn nothing.
depends on matter_status.h · used by matter_case.c
API
Cstruct matter_case_sigma1
What Sigma1 carries. Pointers borrow the caller's buffer; nothing is copied.
Cstruct matter_case_sigma2_in
What building a Sigma2 needs, and nothing it can derive for itself. Gathered into one struct because the alternative is an eleven-argument function whose adjacent 32-byte buffers can be swapped without the compiler noticing -- and two of them, the random and the transcript hash, are both 32 bytes and both feed the same salt.
Cstruct matter_case_sigma3_out
Who the Sigma3 proved its sender to be.
Cstruct matter_case_sigma3_in
What opening a Sigma3 needs, all of it already in hand by then.
##define MATTER_OP_CASE_SIGMA1 0x30u
##define MATTER_OP_CASE_SIGMA2 0x31u
##define MATTER_OP_CASE_SIGMA3 0x32u
##define MATTER_CASE_RANDOM_LEN 32u
Both random values, and the destination identifier, are SHA-256 sized.
##define MATTER_CASE_DEST_ID_LEN 32u
##define MATTER_CASE_IPK_LEN 16u
An operational group key, and the epoch key it comes from.
##define MATTER_CASE_PUBKEY_LEN 65u
Uncompressed P-256 point.
Fint matter_case_operational_ipk(const uint8_t epoch_key[MATTER_CASE_IPK_LEN], const uint8_t compressed_fabric_id[8], uint8_t out[MATTER_CASE_IPK_LEN])
Derive the operational identity protection key from the epoch key. HKDF-SHA256(ikm = the IPK AddNOC delivered, salt = the compressed fabric id, info = "GroupKey v1.0", len = 16) AddNOC hands over an EPOCH key, and every use of "the IPK" in CASE means the operational key derived from it -- CHIP's own GetIpkKeySet() returns operational_keys[].encryption_key, not the epoch key it was given (credentials/GroupDataProviderImpl.cpp). Skipping this step produces a destination identifier that never matches, with nothing to say why.
Fint matter_case_destination_id(const uint8_t ipk[MATTER_CASE_IPK_LEN], const uint8_t initiator_random[MATTER_CASE_RANDOM_LEN], const uint8_t root_pub[MATTER_CASE_PUBKEY_LEN], uint64_t fabric_id, uint64_t node_id, uint8_t out[MATTER_CASE_DEST_ID_LEN])
Recompute the destination identifier an initiator claims. HMAC-SHA256(key = operational IPK, msg = initiatorRandom || rootPublicKey || fabricId || nodeId) fabricId and nodeId are LITTLE-endian here, which is worth stating because the compressed fabric identifier salts with the fabric id BIG-endian. Two derivations, one field, opposite orders; both encode cleanly and only one matches a real phone. @param root_pub uncompressed, 65 bytes, INCLUDING its 0x04 -- unlike the compressed fabric id, which drops it.
Fint matter_case_sigma1_decode(const uint8_t *tlv, size_t len, struct matter_case_sigma1 *out)
Decode a Sigma1 (CASESession.cpp:74-83). The three fixed-length fields are checked against their lengths rather than merely read: a Sigma1 whose ephemeral key is not a P-256 point cannot lead anywhere, and refusing it here is cheaper than discovering it inside ECDH. @return MATTER_OK, MATTER_E_INVAL if a mandatory field is missing or mis-sized, or whatever the TLV decoder returned.
##define MATTER_CASE_SIG_LEN 64u
Raw ECDSA P-256 signature, and a shared secret.
##define MATTER_CASE_SECRET_LEN 32u
##define MATTER_CASE_SIGMA2_MAX 1024u
Enough for a Sigma2: two certificates, a signature and the framing.
Fint matter_case_sigma2_encode(const struct matter_case_sigma2_in *in, uint8_t *out, size_t cap, size_t *out_len, uint8_t shared_out[MATTER_CASE_SECRET_LEN])
Build the Sigma2 answering a Sigma1. shared = ECDH(responderEphPriv, initiatorEphPub) S2K = HKDF(shared, salt = IPK || responderRandom || responderEphPubKey || transcriptHash, info = "Sigma2", 16) TBSData2 = { NOC, ICAC?, responderEphPubKey, initiatorEphPubKey } TBEData2 = { NOC, ICAC?, Sign(opPriv, TBSData2), resumptionID } Sigma2 = { responderRandom, responderSessionId, responderEphPubKey, AES-CCM(TBEData2, S2K, "NCASE_Sigma2N") } The signature covers the EPHEMERAL keys of both sides, which is what stops a recorded Sigma2 being replayed into another handshake: the certificate chain inside it is public, and only the binding to this exchange's keys is not. @param shared_out receives the ECDH secret, which Sigma3 and the session keys both still need. Wiped by the caller, not here. @return MATTER_OK, MATTER_E_NOSPACE, MATTER_E_INVAL, or MATTER_E_STATE when a crypto primitive failed.
##define MATTER_CASE_SIGMA3_MAX 1024u
Enough for a Sigma3: a certificate chain, a signature and the framing.
Fint matter_case_sigma3_open(const struct matter_case_sigma3_in *in, const uint8_t *tlv, size_t len, struct matter_case_sigma3_out *out)
Open and check a Sigma3, the initiator's half of the same proof. S3K = HKDF(shared, salt = IPK || TranscriptHash(Sigma1 || Sigma2), info = "Sigma3", 16) TBEData3 = AES-CCM-open(encrypted3, S3K, "NCASE_Sigma3N") = { initiatorNOC, initiatorICAC?, signature } TBSData3 = { initiatorNOC, initiatorICAC?, initiatorEphPubKey, responderEphPubKey } and the signature over TBSData3 must verify under the public key inside the NOC. Note the tag order: TBSData3 names the SENDER's key first, so Sigma3 puts the initiator's ephemeral key where Sigma2 put the responder's. Getting that backwards still encodes, still decodes, and never verifies. WHAT THIS DOES NOT DO: it does not walk the certificate chain. The signature proves the sender holds the key its NOC names, and the caller is expected to compare the fabric id against its own, but nothing here checks that the NOC was issued by the fabric's root. A peer that reached this point already proved possession of the IPK to get its destination identifier accepted, so this is a narrowing rather than a hole -- but it IS a narrowing, and a complete implementation verifies the chain. @return MATTER_OK, MATTER_E_INVAL for a malformed message, MATTER_E_TYPE if the AEAD tag or the signature failed, MATTER_E_NOSPACE if the message is larger than this node can hold.
Fint matter_case_verify(const uint8_t pub[MATTER_CASE_PUBKEY_LEN], const uint8_t *msg, size_t msg_len, const uint8_t sig[MATTER_CASE_SIG_LEN])
ECDSA-P256-SHA256 verification, provided by the platform. Used to check this node's OWN Sigma2 signature against the certificate it is about to send with it. A peer that rejects a signature says nothing about why, so the only way to tell "signed wrongly" from "derived a different key" is to verify it here, where both halves are in hand. @return 0 when the signature verifies.
Fint matter_case_ecdh(const uint8_t priv[32], const uint8_t peer_pub[MATTER_CASE_PUBKEY_LEN], uint8_t secret_out[MATTER_CASE_SECRET_LEN])
ECDH P-256, provided by the platform. Declared rather than included so this module stays free of any particular crypto backend, the same seam matter_attest.h uses for signing. @return 0 on success.
Fint matter_case_sign(const uint8_t priv[32], const uint8_t *msg, size_t msg_len, uint8_t sig[MATTER_CASE_SIG_LEN])
ECDSA-P256-SHA256 over a raw message. Same seam. @return 0 on success.