diff --git a/tools/xbox/droiddeck-xbox-ticket.patch b/tools/xbox/droiddeck-xbox-ticket.patch new file mode 100644 index 000000000..78093d74d --- /dev/null +++ b/tools/xbox/droiddeck-xbox-ticket.patch @@ -0,0 +1,79 @@ +--- a/crates/xodus/src/models/secrets.rs ++++ b/crates/xodus/src/models/secrets.rs +@@ -80,9 +80,17 @@ + } + } + +-/// Store and licensing services reject delegation-compact tickets in the "d=" +-/// form that Xbox Live sign-in needs (InvalidMsaTicketFormat) and accept the +-/// same value as "t=". Convert only where a ticket is sent to those services. ++// Xbox accepts compact JWE tickets without the delegation prefix; opaque ++// delegation tickets keep their original wire representation. ++pub fn xbox_ticket(ticket: String) -> String { ++ match ticket.strip_prefix("d=") { ++ Some(raw) if is_compact_jwe(raw) => raw.to_owned(), ++ _ => ticket, ++ } ++} ++ ++/// Store services accept delegation-compact tickets in the "t=" form. ++/// Convert only where a ticket is sent to those services. + pub fn store_ticket(ticket: String) -> String { + match ticket.strip_prefix("d=") { + Some(value) => format!("t={value}"), +--- a/crates/xodus/src/models/licensing_ticket_tests.rs ++++ b/crates/xodus/src/models/licensing_ticket_tests.rs +@@ -1,5 +1,5 @@ + //! Public synthetic data only, no account, keyring or network access. +-use super::{Token, licensing_ticket, store_ticket}; ++use super::{Token, licensing_ticket, store_ticket, xbox_ticket}; + use crate::models::soap; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use serde_json::json; +@@ -77,3 +77,35 @@ + let long_header = jwe(json!({"alg":"dir","enc":"A256GCM","kid":"a".repeat(9000)}), ""); + assert_eq!(licensing_ticket(format!("d={long_header}")), format!("t={long_header}")); + } ++ ++#[test] ++fn credential_xbox_ticket_compact_jwe_uses_bare_wire_format_without_changing_store_conversion() { ++ for raw in [ ++ jwe(json!({"alg":"dir","enc":"A128CBC-HS256"}), ""), ++ jwe(json!({"alg":"RSA-OAEP-256","enc":"A256GCM"}), "a2V5"), ++ ] { ++ let Token::Compact(original) = delegation(&raw) else { panic!("fixture"); }; ++ assert_eq!(original, format!("d={raw}")); ++ assert_eq!(store_ticket(original.clone()), format!("t={raw}")); ++ assert_eq!(xbox_ticket(original), raw); ++ assert_eq!(xbox_ticket(raw.clone()), raw); ++ assert_eq!(xbox_ticket(format!("t={raw}")), format!("t={raw}")); ++ } ++} ++ ++#[test] ++fn credential_xbox_ticket_opaque_and_malformed_tokens_are_preserved() { ++ let valid = jwe(json!({"alg":"dir","enc":"A256GCM"}), ""); ++ let mut cases = vec!["d=opaque".to_string(), "opaque".into(), "".into(), "d=".into(), ++ "d=opaque..iv.ciphertext.tag".into(), "d=eyJ..a.b.c".into(), ++ format!("d={valid}.extra"), format!("d={}", valid.replace("..", ".+.")), ++ format!("d={}", jwe(json!({"alg":"dir"}), "")), ++ format!("d={}", jwe(json!({"alg":"dir","enc":""}), "")), ++ format!("d={}", jwe(json!({"alg":"dir","enc":"A256GCM","kid":"a".repeat(9000)}), ""))]; ++ let mut parts: Vec<_> = valid.split('.').map(str::to_string).collect(); ++ for index in [2, 3, 4] { ++ let old = parts[index].clone(); parts[index].clear(); ++ cases.push(format!("d={}", parts.join("."))); parts[index] = old; ++ } ++ for ticket in cases { assert_eq!(xbox_ticket(ticket.clone()), ticket); } ++} +--- a/crates/xodus/src/api/xbox/auth.rs ++++ b/crates/xodus/src/api/xbox/auth.rs +@@ -12,7 +12,7 @@ + properties: UserAuthProperties { + auth_method: "RPS".to_string(), + site_name: "user.auth.xboxlive.com".to_string(), +- rps_ticket, ++ rps_ticket: crate::models::secrets::xbox_ticket(rps_ticket), + }, + }; + diff --git a/tools/xbox/prepare.py b/tools/xbox/prepare.py index 47564ad11..8d435d34b 100755 --- a/tools/xbox/prepare.py +++ b/tools/xbox/prepare.py @@ -10,7 +10,7 @@ recipe = Path(__file__).resolve().parent cache = Path(sys.argv[1]).resolve() lock = json.loads((recipe / 'backend.lock.json').read_text()) local_patches = ['droiddeck-credentials.patch', 'droiddeck-launch-view.patch', 'droiddeck-library-subscriptions.patch', - 'droiddeck-credential-fallback.patch', 'droiddeck-credential-diagnostics.patch', 'droiddeck-cloud-token.patch'] + 'droiddeck-credential-fallback.patch', 'droiddeck-credential-diagnostics.patch', 'droiddeck-cloud-token.patch', 'droiddeck-xbox-ticket.patch'] identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + b''.join((recipe / name).read_bytes() for name in local_patches)).hexdigest() source = cache / ('source-' + identity[:16]) if not (source / '.prepared').is_file():