Fix Xbox authentication with compact JWE tickets

Remove the d= prefix from recognized compact JWE tickets at the Xbox
authentication boundary, preserving opaque tickets and other token formats.

This fixes HTTP 400 responses that blocked game downloads on a tested
AYN Thor build. Add two regression tests and register the patch in prepare.py.
This commit is contained in:
Bogdan Drzewiecki committed 2026-10-28 20:40:46 +01:00
1 parent 6d3ae89d62
commit d55e05a0c5
2 files changed
+80 -1

No files matched your search

+79
View File
@@ -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),
},
};
+1 -1
View File
@@ -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():