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:
4 files changed
+25
-20
No files matched your search
@@ -5,23 +5,21 @@ use sha2::Sha256;
|
|||||||
|
|
||||||
use crate::error::CmafError;
|
use crate::error::CmafError;
|
||||||
|
|
||||||
const RNG_INIT: &str = "abb21364945c0583309667d13ca3d93a";
|
|
||||||
|
|
||||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||||
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
|
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
|
||||||
|
|
||||||
fn hex_decode(hex: &str) -> Vec<u8> {
|
fn hex_decode(hex: &str) -> Vec<u8> {
|
||||||
(0..hex.len())
|
(0..hex.len())
|
||||||
.step_by(2)
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive the 16-byte session key from the session/start `infos` field.
|
/// Derive the 16-byte session key from the session/start `infos` field.
|
||||||
///
|
///
|
||||||
/// `infos` format: `"salt_b64url.info_b64url"`
|
/// `infos` format: `"salt_b64url.info_b64url"`
|
||||||
/// IKM = hex-decoded RNG_INIT (16 bytes)
|
/// `seed` is the hex-encoded app seed used as IKM for HKDF (provided by caller).
|
||||||
pub fn derive_session_key(infos: &str) -> Result<[u8; 16], CmafError> {
|
pub fn derive_session_key(seed: &str, infos: &str) -> Result<[u8; 16], CmafError> {
|
||||||
let parts: Vec<&str> = infos.split('.').collect();
|
let parts: Vec<&str> = infos.split('.').collect();
|
||||||
if parts.len() < 2 {
|
if parts.len() < 2 {
|
||||||
return Err(CmafError::InvalidInfos(
|
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 salt = URL_SAFE_NO_PAD.decode(parts[0])?;
|
||||||
let info = URL_SAFE_NO_PAD.decode(parts[1])?;
|
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 hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
|
||||||
let mut okm = [0u8; 16];
|
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.
|
/// 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.
|
/// then returns the lowercase hex MD5 digest.
|
||||||
|
/// `seed` is the app seed provided by the caller.
|
||||||
pub fn compute_request_sig(
|
pub fn compute_request_sig(
|
||||||
method: &str,
|
method: &str,
|
||||||
args: &std::collections::BTreeMap<&str, String>,
|
args: &std::collections::BTreeMap<&str, String>,
|
||||||
timestamp: &str,
|
timestamp: &str,
|
||||||
|
seed: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ pub fn compute_request_sig(
|
|||||||
hasher.update(v.as_bytes());
|
hasher.update(v.as_bytes());
|
||||||
}
|
}
|
||||||
hasher.update(timestamp.as_bytes());
|
hasher.update(timestamp.as_bytes());
|
||||||
hasher.update(RNG_INIT.as_bytes());
|
hasher.update(seed.as_bytes());
|
||||||
|
|
||||||
format!("{:x}", hasher.finalize())
|
format!("{:x}", hasher.finalize())
|
||||||
}
|
}
|
||||||
@@ -116,21 +116,22 @@ pub fn compute_request_sig(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const TEST_SEED: &str = "00112233445566778899aabbccddeeff";
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_compute_request_sig() {
|
fn test_compute_request_sig() {
|
||||||
let mut args = std::collections::BTreeMap::new();
|
let mut args = std::collections::BTreeMap::new();
|
||||||
args.insert("profile", "qbz-1".to_string());
|
args.insert("profile", "qbz-1".to_string());
|
||||||
let sig = compute_request_sig("sessionstart", &args, "1775500000");
|
let sig = compute_request_sig("sessionstart", &args, "1775500000", TEST_SEED);
|
||||||
assert_eq!(sig.len(), 32); // MD5 hex = 32 chars
|
assert_eq!(sig.len(), 32);
|
||||||
// Verify deterministic
|
let sig2 = compute_request_sig("sessionstart", &args, "1775500000", TEST_SEED);
|
||||||
let sig2 = compute_request_sig("sessionstart", &args, "1775500000");
|
|
||||||
assert_eq!(sig, sig2);
|
assert_eq!(sig, sig2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_decrypt_frame_roundtrip() {
|
fn test_decrypt_frame_roundtrip() {
|
||||||
let key = [0x42u8; 16];
|
let key = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||||||
let iv = [0x01u8; 8];
|
let iv = [1u8, 2, 3, 4, 5, 6, 7, 8];
|
||||||
let original = b"Hello FLAC frame data here!".to_vec();
|
let original = b"Hello FLAC frame data here!".to_vec();
|
||||||
let mut data = original.clone();
|
let mut data = original.clone();
|
||||||
decrypt_frame(&key, &iv, &mut data);
|
decrypt_frame(&key, &iv, &mut data);
|
||||||
@@ -141,7 +142,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_derive_session_key_invalid_infos() {
|
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());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// Generate CMAF signature for session/start endpoint
|
||||||
pub fn sign_session_start(timestamp: u64) -> String {
|
pub fn sign_session_start(timestamp: u64) -> String {
|
||||||
let mut args = std::collections::BTreeMap::new();
|
let mut args = std::collections::BTreeMap::new();
|
||||||
args.insert("profile", "qbz-1".to_string());
|
args.insert("profile", "qbz-1".to_string());
|
||||||
qbz_cmaf::compute_request_sig("sessionstart", &args, ×tamp.to_string())
|
qbz_cmaf::compute_request_sig("sessionstart", &args, ×tamp.to_string(), CMAF_SEED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate CMAF signature for file/url endpoint
|
/// 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("format_id", format_id.to_string());
|
||||||
args.insert("intent", "stream".to_string());
|
args.insert("intent", "stream".to_string());
|
||||||
args.insert("track_id", track_id.to_string());
|
args.insert("track_id", track_id.to_string());
|
||||||
qbz_cmaf::compute_request_sig("fileurl", &args, ×tamp.to_string())
|
qbz_cmaf::compute_request_sig("fileurl", &args, ×tamp.to_string(), CMAF_SEED)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1899,8 +1899,8 @@ impl QobuzClient {
|
|||||||
let resp: SessionStartResponse = response.json().await?;
|
let resp: SessionStartResponse = response.json().await?;
|
||||||
let infos = resp.infos.unwrap_or_default();
|
let infos = resp.infos.unwrap_or_default();
|
||||||
log::info!(
|
log::info!(
|
||||||
"[CMAF] Session started: id={}, expires_at={}",
|
"[CMAF] Session started: id={}..., expires_at={}",
|
||||||
resp.session_id,
|
&resp.session_id[..resp.session_id.len().min(8)],
|
||||||
resp.expires_at
|
resp.expires_at
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ async fn try_cmaf_streaming_setup(
|
|||||||
|
|
||||||
let (_session_id, infos) = bridge.ensure_cmaf_session().await?;
|
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))?;
|
.map_err(|e| format!("Session key derivation failed: {}", e))?;
|
||||||
let content_key = qbz_cmaf::unwrap_content_key(&session_key, key_str)
|
let content_key = qbz_cmaf::unwrap_content_key(&session_key, key_str)
|
||||||
.map_err(|e| format!("Content key unwrap failed: {}", e))?;
|
.map_err(|e| format!("Content key unwrap failed: {}", e))?;
|
||||||
|
|||||||
Reference in new issue
Block a user