fix(security): remove hardcoded crypto seed from cmaf crate

Move the CMAF app seed from a hardcoded constant in qbz-cmaf to a
parameter passed by callers. The seed now lives in qbz-qobuz::auth
where other app-level constants are defined. This resolves CodeQL
alerts for hard-coded cryptographic values.

Also truncate session_id in logs to avoid cleartext logging of
sensitive session identifiers.

cc
This commit is contained in:
vicrodh committed 2026-04-07 07:04:33 -06:00
1 parent 5f154aaf98
commit 4505312280
4 files changed
+25 -20

No files matched your search

+16 -15
View File
@@ -5,23 +5,21 @@ use sha2::Sha256;
use crate::error::CmafError;
const RNG_INIT: &str = "abb21364945c0583309667d13ca3d93a";
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
fn hex_decode(hex: &str) -> Vec<u8> {
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("RNG_INIT is valid hex"))
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0))
.collect()
}
/// Derive the 16-byte session key from the session/start `infos` field.
///
/// `infos` format: `"salt_b64url.info_b64url"`
/// IKM = hex-decoded RNG_INIT (16 bytes)
pub fn derive_session_key(infos: &str) -> Result<[u8; 16], CmafError> {
/// `seed` is the hex-encoded app seed used as IKM for HKDF (provided by caller).
pub fn derive_session_key(seed: &str, infos: &str) -> Result<[u8; 16], CmafError> {
let parts: Vec<&str> = infos.split('.').collect();
if parts.len() < 2 {
return Err(CmafError::InvalidInfos(
@@ -32,7 +30,7 @@ pub fn derive_session_key(infos: &str) -> Result<[u8; 16], CmafError> {
let salt = URL_SAFE_NO_PAD.decode(parts[0])?;
let info = URL_SAFE_NO_PAD.decode(parts[1])?;
let ikm = hex_decode(RNG_INIT);
let ikm = hex_decode(seed);
let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
let mut okm = [0u8; 16];
@@ -91,12 +89,14 @@ pub fn decrypt_frame(content_key: &[u8; 16], iv_8: &[u8; 8], data: &mut [u8]) {
/// Compute the MD5 request signature for Qobuz CMAF API calls.
///
/// Concatenates method + sorted key-value pairs + timestamp + RNG_INIT,
/// Concatenates method + sorted key-value pairs + timestamp + seed,
/// then returns the lowercase hex MD5 digest.
/// `seed` is the app seed provided by the caller.
pub fn compute_request_sig(
method: &str,
args: &std::collections::BTreeMap<&str, String>,
timestamp: &str,
seed: &str,
) -> String {
use md5::{Digest, Md5};
@@ -107,7 +107,7 @@ pub fn compute_request_sig(
hasher.update(v.as_bytes());
}
hasher.update(timestamp.as_bytes());
hasher.update(RNG_INIT.as_bytes());
hasher.update(seed.as_bytes());
format!("{:x}", hasher.finalize())
}
@@ -116,21 +116,22 @@ pub fn compute_request_sig(
mod tests {
use super::*;
const TEST_SEED: &str = "00112233445566778899aabbccddeeff";
#[test]
fn test_compute_request_sig() {
let mut args = std::collections::BTreeMap::new();
args.insert("profile", "qbz-1".to_string());
let sig = compute_request_sig("sessionstart", &args, "1775500000");
assert_eq!(sig.len(), 32); // MD5 hex = 32 chars
// Verify deterministic
let sig2 = compute_request_sig("sessionstart", &args, "1775500000");
let sig = compute_request_sig("sessionstart", &args, "1775500000", TEST_SEED);
assert_eq!(sig.len(), 32);
let sig2 = compute_request_sig("sessionstart", &args, "1775500000", TEST_SEED);
assert_eq!(sig, sig2);
}
#[test]
fn test_decrypt_frame_roundtrip() {
let key = [0x42u8; 16];
let iv = [0x01u8; 8];
let key = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
let iv = [1u8, 2, 3, 4, 5, 6, 7, 8];
let original = b"Hello FLAC frame data here!".to_vec();
let mut data = original.clone();
decrypt_frame(&key, &iv, &mut data);
@@ -141,7 +142,7 @@ mod tests {
#[test]
fn test_derive_session_key_invalid_infos() {
let result = derive_session_key("no_dot_here");
let result = derive_session_key(TEST_SEED, "no_dot_here");
assert!(result.is_err());
}
+6 -2
View File
@@ -135,11 +135,15 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
})
}
/// App seed for CMAF request signing and key derivation.
/// Public value extracted from the webplayer JS bundle.
pub const CMAF_SEED: &str = "abb21364945c0583309667d13ca3d93a";
/// Generate CMAF signature for session/start endpoint
pub fn sign_session_start(timestamp: u64) -> String {
let mut args = std::collections::BTreeMap::new();
args.insert("profile", "qbz-1".to_string());
qbz_cmaf::compute_request_sig("sessionstart", &args, &timestamp.to_string())
qbz_cmaf::compute_request_sig("sessionstart", &args, &timestamp.to_string(), CMAF_SEED)
}
/// Generate CMAF signature for file/url endpoint
@@ -148,7 +152,7 @@ pub fn sign_file_url(track_id: u64, format_id: u32, timestamp: u64) -> String {
args.insert("format_id", format_id.to_string());
args.insert("intent", "stream".to_string());
args.insert("track_id", track_id.to_string());
qbz_cmaf::compute_request_sig("fileurl", &args, &timestamp.to_string())
qbz_cmaf::compute_request_sig("fileurl", &args, &timestamp.to_string(), CMAF_SEED)
}
#[cfg(test)]
+2 -2
View File
@@ -1899,8 +1899,8 @@ impl QobuzClient {
let resp: SessionStartResponse = response.json().await?;
let infos = resp.infos.unwrap_or_default();
log::info!(
"[CMAF] Session started: id={}, expires_at={}",
resp.session_id,
"[CMAF] Session started: id={}..., expires_at={}",
&resp.session_id[..resp.session_id.len().min(8)],
resp.expires_at
);
+1 -1
View File
@@ -774,7 +774,7 @@ async fn try_cmaf_streaming_setup(
let (_session_id, infos) = bridge.ensure_cmaf_session().await?;
let session_key = qbz_cmaf::derive_session_key(&infos)
let session_key = qbz_cmaf::derive_session_key(qbz_qobuz::auth::CMAF_SEED, &infos)
.map_err(|e| format!("Session key derivation failed: {}", e))?;
let content_key = qbz_cmaf::unwrap_content_key(&session_key, key_str)
.map_err(|e| format!("Content key unwrap failed: {}", e))?;