Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions Sources/SakeKit/XboxSignIn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,24 +122,21 @@ public struct XboxSignIn: Sendable {
return try await microsoft.waitForApproval(of: code)
}

/// A bound token has to come from a user token bound to the same key, so a sign-in that
/// mints both kinds asks for two user tokens.
/// Every token comes from the one unbound user token: one bound to the key would give its
/// tokens a user hash of their own, and the runtime labels them all with the identity's.
/// See docs/gdk.md.
private func xboxSession(accessToken: String) async throws -> XboxSession {
let user = try await xbox.userToken(accessToken: accessToken, key: nil)
var bound: (device: XboxDevice, user: XboxToken, token: XboxToken)?
var bound: (device: XboxDevice, token: XboxToken)?
if let device, relyingParties.contains(where: \.boundToDevice) {
bound = (
device,
try await xbox.userToken(accessToken: accessToken, key: device.key),
try await xbox.deviceToken(key: device.key, deviceID: device.id)
)
bound = (device, try await xbox.deviceToken(key: device.key, deviceID: device.id))
}

var tokens: [String: XboxToken] = [:]
for relyingParty in relyingParties {
if relyingParty.boundToDevice, let bound {
tokens[relyingParty.name] = try await xbox.xstsToken(
relyingParty: relyingParty.name, userToken: bound.user, deviceToken: bound.token,
relyingParty: relyingParty.name, userToken: user, deviceToken: bound.token,
key: bound.device.key
)
} else {
Expand Down
13 changes: 7 additions & 6 deletions Tests/SakeKitTests/SignInTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ private func xboxSignIn(

@Test func aRefreshTokenSignsInAgainWithoutACodeAndTheDeviceBindsOnlyWhatWantsIt() async throws {
let server = FakeServer([
(200, tokenAnswer), (200, userAnswer), (200, userAnswer), (200, deviceAnswer),
(200, tokenAnswer), (200, userAnswer), (200, deviceAnswer),
(200, xstsAnswer), (200, playfabAnswer),
])
let device = XboxDevice(key: ProofKey(), id: UUID())
Expand All @@ -454,18 +454,19 @@ private func xboxSignIn(
#expect(result.refreshToken == "the-refresh-token")

let users = server.requests.filter { $0.url?.host() == "user.auth.xboxlive.com" }
#expect(users.count == 2)
#expect(users.count == 1)
#expect(users.first?.value(forHTTPHeaderField: "Signature") == nil)
#expect(try signatureHolds(try #require(users.last), path: "/user/authenticate", key: device.key))

let xsts = server.requests.filter { $0.url?.host() == "xsts.auth.xboxlive.com" }
#expect(try xsts.map { try json($0)["RelyingParty"] as? String } == [
"http://xboxlive.com", "http://playfab.xboxlive.com/",
])
let identity = try #require(try json(xsts[0])["Properties"] as? [String: Any])
#expect(identity["UserTokens"] as? [String] == ["the-user-token"])
#expect(identity["DeviceToken"] == nil)
#expect(xsts[0].value(forHTTPHeaderField: "Signature") == nil)
let playFab = try #require(try json(xsts[1])["Properties"] as? [String: Any])
#expect(playFab["UserTokens"] as? [String] == ["the-user-token"])
#expect(playFab["DeviceToken"] as? String == "the-device-token")
#expect(try signatureHolds(xsts[1], path: "/xsts/authorize", key: device.key))
}
Expand Down Expand Up @@ -554,7 +555,7 @@ private func permissions(of url: URL) throws -> Int? {
defer { remove(paths) }
let request = try titleAsking(in: paths)
let server = FakeServer([
(200, codeAnswer), (200, tokenAnswer), (200, userAnswer), (200, userAnswer), (200, deviceAnswer),
(200, codeAnswer), (200, tokenAnswer), (200, userAnswer), (200, deviceAnswer),
(200, xstsAnswer), (200, playfabAnswer),
])

Expand Down Expand Up @@ -596,7 +597,7 @@ private func permissions(of url: URL) throws -> Int? {
XboxAccount(refreshToken: "an-old-refresh-token", device: device), for: "0000000012345678"
)
let server = FakeServer([
(200, tokenAnswer), (200, userAnswer), (200, userAnswer), (200, deviceAnswer),
(200, tokenAnswer), (200, userAnswer), (200, deviceAnswer),
(200, xstsAnswer), (200, playfabAnswer),
])

Expand Down Expand Up @@ -639,7 +640,7 @@ private func permissions(of url: URL) throws -> Int? {
for: "0000000012345678"
)
let server = FakeServer([
(200, tokenAnswer), (200, userAnswer), (200, userAnswer), (200, deviceAnswer),
(200, tokenAnswer), (200, userAnswer), (200, deviceAnswer),
(401, #"{"XErr":2148916238}"#),
])

Expand Down
62 changes: 48 additions & 14 deletions docs/gdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ reached character select with them and nothing else on 2026-09-30: the runtime,
`xgameruntime/`, asks sake to sign the person in when the game first wants a token, sake does
it through files in the bottle, and the runtime hands the game its user and tokens. Later that
day the app built the runtime in setup and put it in the bottle itself, and the game reached
character select again.** What was measured says so and gives the date; the rest is a
character select again.** That evening its account link worked from the Mac too, once PlayFab's
token came from the same user token as the rest, and two players who joined a party by code
played a mission together. What was measured says so and gives the date; the rest is a
decision or an open question.

## What was measured
Expand Down Expand Up @@ -257,6 +259,37 @@ setup, then Steam started from sake in the `ex` bottle.
and with the sign-in kept the game reached character select, every token from the session.
The start after that found the copy already there.

On the evening of 2026-09-30 the game's account link and its play with others were measured in
the `ex` bottle, with sake's runtime in `system32` and the game's WinHTTP traced, and against
the owner's Windows PC, where Steam runs the same game on Gaming Services.

- **Account link is the game's own.** Its settings link the Steam account to a Microsoft one
through Mojang's service: `POST vex.minecraftservices.com/account/steam/link` with the
PlayFab and Minecraft tokens the runtime hands over and a Steam ticket, and `…/unlink` to
undo it. With sake's tokens, unlinking worked and linking answered 500 with no reason in its
body, nine times in three tries, which the game showed as "Error code: 0029". On Windows the
same link worked. The person's characters stayed while the accounts were unlinked.
- **The user hash was the difference.** An XSTS token minted from a user token bound to the
device's key names a different user hash from one minted from the unbound user token, and
the runtime puts one user hash, the identity's, into every `Authorization` value. sake had
minted PlayFab's from the bound one, so the game sent PlayFab's token under another hash.
Minted instead from the unbound user token, with the device's token beside it and the
request signed by the device's key, the link answered 200 and the game said the accounts
were linked. Minecraft's token was bound to nothing throughout, so the link does not need a
title claim.
- **Multiplayer activity needs a title.** `multiplayeractivity.xboxlive.com` answered 401 with
`"debugMessage": "Missing title Id claim."` to sake's token, bound or unbound, signed or not.
XSAPI asks for a new token with `ForceRefresh` on every 401, so each wave cost sake a silent
sign-in, and enough of them brought 429.
- **No title token through SISU's page either.** SISU's `/authenticate` hands out a session for
this `MSAAppId` without checking the redirect. The page it returns sends the person to
`oauth20_authorize.srf` with `ms-xal-00000000497c1b94://auth`, which login.live.com refuses as
not registered for the app, before anyone signs in, and `/authorize` refuses the device
code's token with the session or without.
- **Playing together.** Parties, invites and matchmaking are Mojang's, `/spicewood/…` on the
same service, and the game servers PlayFab's. Joining a party by code worked both ways with
the game on a Nintendo Switch 2, and with the Mac hosting, the two played a mission together.

## WineGDK

`Weather-OS/WineGDK` implements `xgameruntime` inside Wine 11.14. Its author declares their
Expand Down Expand Up @@ -322,8 +355,10 @@ title declares that ID for exactly this, and sake signs in as no other applicati
tokens are minted for `http://xboxlive.com`, for `http://playfab.xboxlive.com/`, and for
whatever a table sake keeps names for the title, which for Minecraft Dungeons II is
`rp://api.minecraftservices.com/`. Only PlayFab's is bound to a device, because the
stand-in's README says PlayFab will not link an account otherwise; the rest are bound to
nothing, and the device's key never leaves sake.
stand-in's README says PlayFab will not link an account otherwise, and it comes from the same
user token as the rest: the runtime labels every token with one user hash, and a user token
bound to the key would give PlayFab's another, which is what made the link fail until
2026-09-30 (above). The rest are bound to nothing, and the device's key never leaves sake.

**The user the runtime hands over.** One user, whose handle is one object's address. The
silent add returns at once: with the person the session names while its identity token lasts
Expand Down Expand Up @@ -379,8 +414,7 @@ The refresh token stays out of the bottle.
signature from the key its token is bound to, and the stand-in's log had none of the 416
requests whose headers it recorded carrying a `Signature`. With sake's unsigned tokens,
PlayFab's bound to the device and the rest to nothing, the game reached character select.
Whether a signature, or a title token, is what the multiplayer activity service wants
(below) is not known.
The multiplayer activity service wants a title token (below), not a signature.
- **Whether tokens outlive a session.** XSTS tokens last 16 hours and the user token 96. The
runtime asks sake again for a token with less than five minutes left, and a kept sign-in
makes that silent, but no session has yet run long enough to need it.
Expand All @@ -401,15 +435,15 @@ The refresh token stays out of the bottle.
- **Security information for a URL** is answered with TLS 1.2 and no pinned certificates.
The game asks for it, in UTF-16, before every request it sends, and on 2026-09-30 its
requests went through with that answer.
- **Multiplayer activity.** `multiplayeractivity.xboxlive.com` looks to refuse sake's token,
and each refusal costs a silent refresh through sake (above), because the runtime takes a
`ForceRefresh` at its word. Neither run needed the service, and the stand-in's calls to it
failed too.
- **Linking a Steam account PlayFab does not know yet.** Every run with sake's sign-in found the
owner's Steam login already linked to their XUID. The stand-in had signed the same account in
before sake's first run, and whether the link came from that is not known. The stand-in's
README says PlayFab will not link an account without a token bound to a device, which is why
sake binds PlayFab's; an account PlayFab has not seen has not been tried.
- ~~**Multiplayer activity.**~~ **Answered on 2026-09-30**, above: it wants a title Id claim,
so a title token, and sake has none: XAST refuses the device code's token, so does SISU's
`/authorize`, and SISU's page wants a redirect this app has not registered. Each refusal
still costs a silent sign-in through sake, because the runtime takes a `ForceRefresh` at its
word. The game is played without it, and with others by party code.
- **Linking a Steam account PlayFab does not know yet.** Unlinking and linking again works
since 2026-09-30 (above), with PlayFab's token bound to the device the way the stand-in's
README asks; a PlayFab token bound to nothing was not tried, and neither was an account
PlayFab has not seen.
- ~~**How to pin libHttpClient.**~~ **Answered on 2026-09-30**: by what it unpacks to. GitHub's
page on downloading source code archives, read that day, promises that an archive of a
commit ID always has the same files, and not the same bytes: the compression may change,
Expand Down
Loading