# ============================================================================ # Get-Wishes.ps1 - READ-ONLY. Do not add a write endpoint to this file. # # The same driver as get_wishes.py, in PowerShell, with no Python, no modules # and nothing to install: paste the one-liner and it runs. # # irm /Get-Wishes.ps1 | iex # # It logs in like the game client and reads exactly two things: # POST /api/version/resource (what content roll is live) # POST /api/gacha/history (the player's own draw ledger) # plus the client's own pre-login handshake (/api/version/index, # /api/feature/device-feature, /api/maintenance/index) and the login # (/api/login/nonce, /api/login/index). # # NEVER call /api/gacha/play, /api/gacha/select, /api/gacha/stepup, # /api/nonce/create or any other endpoint that rolls, buys, spends, grants or # saves anything. The puller's whole promise to the player is that pasting the # one-liner cannot change their account. If a future change wants "one more # field" and that field needs a mutating call, the change is wrong. # # get_wishes.py stays the reference and the fallback: this file is a port of it # function by function, with the same constants, the same byte operations and # the same request order. Where Python used a package this file uses a .NET # type that ships with Windows: # # AES-CBC/PKCS7 System.Security.Cryptography.Aes (not hand-rolled) # RSA PKCS#1 v1.5 System.Security.Cryptography.RSA + a small ASN.1 reader # (ImportPkcs8PrivateKey does not exist on 5.1) # SQLite a minimal read-only reader in this file (no # System.Data.SQLite: it is not on a clean machine) # HTTP System.Net.HttpWebRequest (HttpClient is not on 5.1) # # Runs on Windows PowerShell 5.1 and PowerShell 7. The source is pure ASCII on # purpose: a .ps1 that is fetched and piped into iex must survive any charset # guess, so the ellipsis and the game's Japanese folder name are built from # codepoints rather than written literally. # # READ-ONLY, and never prints key material: the run logs the store path, why # that store was chosen, and a sha256 prefix of the PEM - never a value from the # store, never a decrypted row, never a cookie. # # .\Get-Wishes.ps1 --whatif print the plan, touch no network # .\Get-Wishes.ps1 pull, write the artifact, print the link # .\Get-Wishes.ps1 --user-id 123 --device-id abc... # .\Get-Wishes.ps1 --selftest offline checks of every ported primitive # # Under `irm | iex` there is no way to pass a flag, so $env:MTCOE_WHATIF=1 is # the one-liner's safe mode. # # The signing key, the account id and the device id are read from the game's own # store, %LOCALAPPDATA%\GameLib\\auth.db - see AUTHDB_* below and # ../mushoku-tensei/mt_db/re/LOCAL_IDENTITY.md, rounds 1-2. # # Exit codes, like the driver: 0 ok, 2 bad arguments, 3 a UserError the player # can fix, and 130 on interrupt (best effort - PowerShell does not always raise # Ctrl+C as a catchable exception, and a stop inside a blocking socket read is # one of the cases it does not). They are set when this file is run as a # script; under `irm | iex` the script deliberately does not call exit, because # closing the window would throw away the link and the paste path it just # printed. # ============================================================================ # No param() block, on purpose. Advanced parameter binding changes how the # driver's CLI is read: `--days 0` binds to a declared -Days (so a legitimate 0 # is indistinguishable from "not passed"), and `--out` collides with the common # parameters -OutVariable/-OutBuffer ("the parameter name 'out' is ambiguous"). # Everything arrives in $args instead, and Read-MtcoeArguments parses both # spellings - the driver's and PowerShell's: # # .\Get-Wishes.ps1 --whatif .\Get-Wishes.ps1 -WhatIf # .\Get-Wishes.ps1 --days 45 .\Get-Wishes.ps1 -Days 45 # .\Get-Wishes.ps1 --user-id 123 --device-id abc... # # PowerShell does not understand `--flag` for a function's parameters, which is # why the one-liner's flags are read from the raw argument list. # --------------------------------------------------------------------------- # Everything lives inside one function, so that a piped `irm ... | iex` run # leaves no variables and no helper functions behind in the caller's session. # The exit code comes back through a [ref] instead of `exit`, so the cleanup in # the finally blocks always runs. # --------------------------------------------------------------------------- function Invoke-MtcoePuller { param( [string]$ScriptRoot, [string[]]$RawArgs, [ref]$ExitCode ) # StrictMode 2.0 here, in this function's scope only: it catches typo'd # variables without changing the settings of whoever's session we run in. Set-StrictMode -Version 2.0 # ======================================================================== # Constants - copied from get_wishes.py, same values. # ======================================================================== $DriverVersion = '1.0.0' # The artifact's `puller` field. The driver's value is "get-wishes.ps1 # 1.0.0"; the "-ps" suffix is the one deliberate deviation, so that an # artifact can be traced back to the implementation that wrote it # (docs/ARTIFACT.md: "so a broken build is identifiable"). $PullerName = "get-wishes.ps1 $DriverVersion-ps" $ApiHost = 'https://webapi-jp.games.mushokutensei-coe.com' $HistoryEndpoint = '/api/gacha/history' #: The body cipher key, recovered from the client (webapi_forge). It is a #: constant of the build, not of the account, so it is safe to ship. $BodyKey = [System.Text.Encoding]::ASCII.GetBytes('_YAhrDYV9Rzbf}Os@%ip(s0y7eSADnvI') #: HMACSHA512 key for the login body signature (webapi_login). Also a build #: constant, and the value the service accepts today. $LoginHmacKey = [System.Text.Encoding]::ASCII.GetBytes('MdoL!#54AsO%EwXE0#Ak~jq6&Nj336Dm') #: Every authenticated call must carry a resource roll the service still #: knows. This is only the *placeholder* used for the one call that fetches #: the live value (a stale roll is refused with error 5 on /api/login/index, #: so the running value is read from /api/version/resource, never trusted #: from here). $ResourceIdPlaceholder = '20260918091330843ee' #: The header block the PC client sends. Same values as #: account_create.STENCILS. APP-OS-VERSION and APP-DEVICE-MODEL are read #: from THIS machine at startup (see Get-HostIdentity), so every install #: sends its own real OS build and board - the same strings its own game #: client sends - instead of one shared stencil that would make every tool #: user look like one account. The literals below are the fallback used #: only when a value cannot be read. $Stencil = [ordered]@{ 'APP-OS' = '5' 'APP-OS-VERSION' = 'Windows 11 (10.0.26200)' 'APP-DEVICE-MODEL' = 'System Product Name (ASUS)' 'APP-LOCALE' = 'ja-jp' 'APP-COMMIT-ID' = 'b0f209844aca34c6ad4f56b895c53e5c3421a6d1' 'X-Country-Code' = 'jp' } #: Fallback APP-APP-VER. The live value is read from the installed game #: (Read-AppVerFromInstall); this is only used when the install cannot be #: found, and is deliberately visible so a stale default is obvious. $FallbackAppVer = '1.2.0' $DaysBack = 90 # constant.csv: gacha_history_request_days_back $MaxDays = 90 $DefaultSite = 'https://mtcoe.com/#/wishes' #: Entity slots in the reply's EntitySchemaList (proto_spec.json). $SlotGachaExec = 53 $SlotGachaResult = 54 #: Characters the source cannot hold literally (see the header note). $Ellipsis = [string][char]0x2026 #: `EnumAccessPoint` (pc_client/il2cpp_dump/dump.cs:2925). $AccessPoints = @{ 0 = 'None'; 1 = 'Dev'; 2 = 'Submit'; 3 = 'Local'; 4 = 'Sandbox' 5 = 'Sandboxdev'; 6 = 'Cbt'; 7 = 'Review'; 8 = 'Sandboxcbt' 9 = 'Rudy'; 99 = 'Production' } #: A plausible APP-PUBLIC-USER-ID. Every id this project has recorded is 11 #: digits (LOCAL_IDENTITY.md), so a decrypted value outside this band is a #: wrong key or a wrong field, not an id, and is refused rather than returned. $UserIdMin = [int64]1000000000 $UserIdMax = [int64]1000000000000 # exclusive # ------------------------------------------------ the GameLib store cipher # # The store's cipher, recovered from `cpp_gamelib_dmm.dll` and proven two # ways (every row unpads under it; the archived 2026-09-17 ciphertext # decrypts to a PEM that reproduces signatures captured independently from # BIO_new_mem_buf). See ../mushoku-tensei/mt_db/re/LOCAL_IDENTITY.md # rounds 1-2, reference implementation `_kf2_authdb.py`: # # key string = "43500f701738404537F9C3478CFC" + getProperty("ksrle") (="" here) # AES key = first 16 bytes of its UTF-8 -> AES-128, not AES-256 # IV = MD5(UTF-8 of the whole key string) # mode = AES-128-CBC / PKCS#7; every _VALUE stored as lowercase hex # # The 28-character constant is a *compiled-in constant of the build*, not an # account credential - the same kind of thing as BODY_KEY and LOGIN_HMAC_KEY # above - which is why it is in the code. What it decrypts is a credential, # and nothing decrypted here is ever printed or written anywhere. $AuthDbKeyString = '43500f701738404537F9C3478CFC' $AuthDbKeyBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthDbKeyString) $AuthDbAesKey = New-Object byte[] 16 [Array]::Copy($AuthDbKeyBytes, $AuthDbAesKey, 16) $AuthDbIv = New-Object byte[] 16 $md5 = [System.Security.Cryptography.MD5]::Create() try { $AuthDbIv = $md5.ComputeHash($AuthDbKeyBytes) } finally { $md5.Dispose() } $AuthDbRowKey = 'prv_key' # the APP-SIGNATURE key $AuthDbRowUserId = 'x_uid' # APP-PUBLIC-USER-ID $AuthDbRowDeviceId = 'uuid' # sent as APP-DEVICE-ID $AuthDbTable = '_DATA' $AuthDbGlob = 'GameLib' #: `UserDataCryptoManager`'s static key and IV: AES-256-CBC/PKCS7 over the #: MessagePack the client writes to `LocalSaveData\*.dat`. Per-build client #: literals, not account credentials - the same kind of thing as BODY_KEY. $LocalSaveDataKey = [System.Text.Encoding]::ASCII.GetBytes('Lb~gT9S"?d| field 1. if ($null -eq $Reply -or $Reply.Length -eq 0) { return $null } foreach ($payload in (Get-PbPayloads $Reply 2)) { $err = Get-PbOne $payload if ($err.ContainsKey(1) -and ($err[1] -is [uint64])) { return [int]$err[1] } } return $null } function Get-First64Hex([byte[]]$Reply) { # re.search(rb"[0-9a-f]{64}", reply): the fallback nonce. $run = 0 $start = 0 for ($i = 0; $i -lt $Reply.Length; $i++) { $b = $Reply[$i] $isHex = (($b -ge 0x30) -and ($b -le 0x39)) -or (($b -ge 0x61) -and ($b -le 0x66)) if ($isHex) { if ($run -eq 0) { $start = $i } $run++ if ($run -eq 64) { return [System.Text.Encoding]::ASCII.GetString($Reply, $start, 64) } } else { $run = 0 } } return '' } function Get-Walk([byte[]]$Data, [int]$Depth) { # A printable field-by-field decode, for diagnostics only. $lines = New-Object 'System.Collections.Generic.List[string]' if ($Depth -gt 4) { $lines.Add(''); return , $lines } try { foreach ($f in (Read-PbFields $Data)) { if ($f.W -eq 2) { $v = [byte[]]$f.V $printable = $true foreach ($b in $v) { if ($b -lt 32 -or $b -ge 127) { $printable = $false; break } } if ($printable -and $v.Length -le 60) { $lines.Add("field $($f.F): '$((Get-Utf8String $v))'") } else { $lines.Add("field $($f.F): message[$($v.Length)]") foreach ($l in (Get-Walk $v ($Depth + 1))) { $lines.Add(" $l") } } } elseif ($f.W -eq 0) { $lines.Add("field $($f.F): varint $($f.V)") } else { $lines.Add("field $($f.F): wire $($f.W)") } } } catch { $lines.Add("") } return , $lines } # ================================================================== lz-string # # LZString.compressToEncodedURIComponent, ported (the site decodes the `?d=` # payload with the JavaScript implementation, so the characters have to be # identical). A PowerShell string is already UTF-16 code units, which is # exactly what charCodeAt walks - so ToCharArray() replaces the driver's # utf-16-le re-encode step, and nothing else changes. # # The driver keys its dictionary on tuples of code units; the equivalent # here is the units joined with commas, which cannot collide (a unit is # 0..65535, so it never contains a comma). # ======================================================================== function Get-LzUriPayload([string]$Text) { $bitsPerChar = 6 $safe = $LzUriSafe $data = New-Object System.Text.StringBuilder $st = [pscustomobject]@{ Val = 0; Pos = 0 } function Add-LzBits([object]$State, [int]$Value, [int]$NumBits) { for ($k = 0; $k -lt $NumBits; $k++) { $State.Val = ($State.Val -shl 1) -bor ($Value -band 1) if ($State.Pos -eq $bitsPerChar - 1) { [void]$data.Append($safe[$State.Val]) $State.Val = 0 $State.Pos = 0 } else { $State.Pos++ } $Value = $Value -shr 1 } } $dict = New-Object 'System.Collections.Generic.Dictionary[string,int]' $toCreate = New-Object 'System.Collections.Generic.HashSet[string]' $wKey = '' $wFirst = 0 $wHasUnits = $false $enlargeIn = 2 $dictSize = 3 $numBits = 2 $units = $Text.ToCharArray() for ($i = 0; $i -lt $units.Length; $i++) { $c = [int]$units[$i] $cKey = [string]$units[$i] if (-not $dict.ContainsKey($cKey)) { $dict[$cKey] = $dictSize $dictSize++ [void]$toCreate.Add($cKey) } if ($wHasUnits) { $wcKey = $wKey + ',' + $c } else { $wcKey = $cKey } if ($dict.ContainsKey($wcKey)) { $wKey = $wcKey if (-not $wHasUnits) { $wFirst = $c; $wHasUnits = $true } continue } if ($toCreate.Contains($wKey)) { if ($wFirst -lt 256) { Add-LzBits $st 0 $numBits Add-LzBits $st $wFirst 8 } else { Add-LzBits $st 1 $numBits Add-LzBits $st $wFirst 16 } $enlargeIn-- if ($enlargeIn -eq 0) { $enlargeIn = 1 -shl $numBits; $numBits++ } [void]$toCreate.Remove($wKey) } else { Add-LzBits $st $dict[$wKey] $numBits } $enlargeIn-- if ($enlargeIn -eq 0) { $enlargeIn = 1 -shl $numBits; $numBits++ } $dict[$wcKey] = $dictSize $dictSize++ $wKey = $cKey $wFirst = $c $wHasUnits = $true } if ($wHasUnits) { if ($toCreate.Contains($wKey)) { if ($wFirst -lt 256) { Add-LzBits $st 0 $numBits Add-LzBits $st $wFirst 8 } else { Add-LzBits $st 1 $numBits Add-LzBits $st $wFirst 16 } $enlargeIn-- if ($enlargeIn -eq 0) { $enlargeIn = 1 -shl $numBits; $numBits++ } [void]$toCreate.Remove($wKey) } else { Add-LzBits $st $dict[$wKey] $numBits } $enlargeIn-- if ($enlargeIn -eq 0) { $enlargeIn = 1 -shl $numBits; $numBits++ } } Add-LzBits $st 2 $numBits # end of stream while ($true) { # flush $st.Val = $st.Val -shl 1 if ($st.Pos -eq $bitsPerChar - 1) { [void]$data.Append($safe[$st.Val]); break } $st.Pos++ } return $data.ToString() } # ================================================================== JSON # # ConvertTo-Json is not used: it reorders nothing but it formats floats and # escapes characters its own way, and both the artifact's bytes and the # `?d=` payload's bytes have to match what the driver's json.dumps produces. # This writer does the three shapes the driver uses: # # -Compact separators=(",", ":"), ensure_ascii=True # -Spaced default separators (", ", ": ") # -Indent 2 indent=2, ensure_ascii=False # ======================================================================== function ConvertTo-JsonString([string]$Text, [bool]$Ascii) { $sb = New-Object System.Text.StringBuilder [void]$sb.Append('"') foreach ($ch in $Text.ToCharArray()) { $c = [int]$ch if ($ch -eq '"') { [void]$sb.Append('\"') } elseif ($ch -eq '\') { [void]$sb.Append('\\') } elseif ($ch -eq [char]8) { [void]$sb.Append('\b') } elseif ($ch -eq [char]12) { [void]$sb.Append('\f') } elseif ($ch -eq [char]10) { [void]$sb.Append('\n') } elseif ($ch -eq [char]13) { [void]$sb.Append('\r') } elseif ($ch -eq [char]9) { [void]$sb.Append('\t') } elseif ($c -lt 0x20) { [void]$sb.Append('\u' + $c.ToString('x4', $Invariant)) } elseif ($Ascii -and $c -gt 0x7F) { [void]$sb.Append('\u' + $c.ToString('x4', $Invariant)) } else { [void]$sb.Append($ch) } } [void]$sb.Append('"') return $sb.ToString() } function ConvertTo-JsonNumber([object]$Value) { if ($Value -is [double] -or $Value -is [single] -or $Value -is [decimal]) { # Python's repr: an integral float keeps its ".0". $s = [Convert]::ToDouble($Value).ToString('R', $Invariant) if ($s.IndexOf('.') -lt 0 -and $s.IndexOf('E') -lt 0 -and $s.IndexOf('e') -lt 0) { $s = $s + '.0' } return $s } return ([int64]$Value).ToString($Invariant) } function ConvertTo-MtcoeJson { param( [object]$Value, [int]$Indent = -1, [bool]$Ascii = $false, [bool]$Spaced = $false, [int]$Level = 0 ) if ($null -eq $Value) { return 'null' } if ($Value -is [bool]) { if ($Value) { return 'true' } return 'false' } if ($Value -is [string]) { return (ConvertTo-JsonString $Value $Ascii) } if ($Value -is [int] -or $Value -is [long] -or $Value -is [uint64] -or $Value -is [int16] -or $Value -is [uint32] -or $Value -is [double] -or $Value -is [single] -or $Value -is [decimal]) { return (ConvertTo-JsonNumber $Value) } $sep = if ($Spaced) { ': ' } else { ':' } $itemSep = if ($Spaced) { ', ' } else { ',' } if ($Value -is [System.Collections.IDictionary]) { $keys = ConvertTo-ObjectArray $Value.Keys if ($keys.Count -eq 0) { return '{}' } $parts = New-Object 'System.Collections.Generic.List[string]' foreach ($k in $keys) { $inner = ConvertTo-MtcoeJson -Value $Value[$k] -Indent $Indent -Ascii $Ascii ` -Spaced $Spaced -Level ($Level + 1) if ($Indent -ge 0) { $pad = ' ' * ($Indent * ($Level + 1)) $parts.Add($pad + (ConvertTo-JsonString ([string]$k) $Ascii) + ': ' + $inner) } else { $parts.Add((ConvertTo-JsonString ([string]$k) $Ascii) + $sep + $inner) } } if ($Indent -ge 0) { $closePad = ' ' * ($Indent * $Level) return '{' + [char]10 + ($parts -join (',' + [char]10)) + [char]10 + $closePad + '}' } return '{' + ($parts -join $itemSep) + '}' } if ($Value -is [System.Collections.IEnumerable]) { $items = ConvertTo-ObjectArray $Value if ($items.Count -eq 0) { return '[]' } $parts = New-Object 'System.Collections.Generic.List[string]' foreach ($item in $items) { $inner = ConvertTo-MtcoeJson -Value $item -Indent $Indent -Ascii $Ascii ` -Spaced $Spaced -Level ($Level + 1) if ($Indent -ge 0) { $parts.Add((' ' * ($Indent * ($Level + 1))) + $inner) } else { $parts.Add($inner) } } if ($Indent -ge 0) { $closePad = ' ' * ($Indent * $Level) return '[' + [char]10 + ($parts -join (',' + [char]10)) + [char]10 + $closePad + ']' } return '[' + ($parts -join $itemSep) + ']' } throw "cannot serialise a $($Value.GetType().FullName)" } function ConvertTo-PlainData([object]$Value) { # ConvertFrom-Json hands back PSCustomObject; the writer above wants # dictionaries and arrays, in the order the file had them. if ($null -eq $Value) { return $null } if ($Value -is [System.Management.Automation.PSCustomObject]) { $out = [ordered]@{} foreach ($p in $Value.PSObject.Properties) { $out[$p.Name] = ConvertTo-PlainData $p.Value } return $out } if ($Value -is [System.Collections.IDictionary]) { $out = [ordered]@{} foreach ($k in $Value.Keys) { $out[$k] = ConvertTo-PlainData $Value[$k] } return $out } if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { $out = New-Object 'System.Collections.Generic.List[object]' foreach ($item in $Value) { $out.Add((ConvertTo-PlainData $item)) } return , $out } return $Value } function Get-JProp([object]$Object, [string]$Name) { if ($null -eq $Object) { return $null } $p = $Object.PSObject.Properties[$Name] if ($null -eq $p) { return $null } return $p.Value } function ConvertFrom-MtcoeJson([string]$Text) { # PowerShell 7 turns ISO-shaped strings into [DateTime] on its own, which # would rewrite an artifact's `pulled_at`/`execs[].at` out of the strings # they are; -DateKind String (7.5+) keeps them. Windows PowerShell 5.1 # leaves them alone already. if ($JsonDateKind) { return (ConvertFrom-Json -InputObject $Text -DateKind String) } return (ConvertFrom-Json -InputObject $Text) } function ConvertTo-ObjectArray([object]$Value) { # A one-level snapshot of a value, where `@($Value)` cannot be used: the # array subexpression throws "Argument types do not match" on a # System.Collections.Generic.List[object] (measured on both 5.1 and 7), and # this port has several of those - the protobuf field lists, the artifact's # execs and results, the MessagePack arrays. if ($null -eq $Value) { return , (New-Object 'object[]' 0) } if ($Value -is [System.Collections.IDictionary] -or $Value -is [string]) { return , ([object[]]@($Value)) } $list = New-Object 'System.Collections.Generic.List[object]' foreach ($item in $Value) { $list.Add($item) } return , $list.ToArray() } # ======================================================================== # Minimal read-only SQLite reader - see the header: PowerShell ships no # SQLite provider and System.Data.SQLite is a download, so the store is read # by hand. Just enough of the format for one table of short text rows: # # * 100-byte header; page size is a big-endian u16 at offset 16, and the # value 1 means 65536 # * page 1 also holds the sqlite_master leaf, so its b-tree header starts # at offset 100 rather than 0 # * table-leaf 0x0D cells: varint payload length, varint rowid, payload # * table-interior 0x05 cells: u32 child page, varint key; the page's # right-most pointer sits at header offset 8 # * a record is a header of varint serial types followed by the values # * a payload that does not fit one page continues down an overflow chain # # Nothing here can write: the file is a throwaway copy, opened for reading. # ======================================================================== function Read-BeU16([byte[]]$Data, [int]$Offset) { return (([int]$Data[$Offset] -shl 8) -bor [int]$Data[$Offset + 1]) } function Read-BeU32([byte[]]$Data, [int]$Offset) { return (([int64]$Data[$Offset] -shl 24) -bor ([int64]$Data[$Offset + 1] -shl 16) -bor ([int64]$Data[$Offset + 2] -shl 8) -bor [int64]$Data[$Offset + 3]) } function Read-SqliteVarint([byte[]]$Data, [int]$Offset, [ref]$Length) { $v = [uint64]0 $n = 0 for ($i = 0; $i -lt 9; $i++) { $b = $Data[$Offset + $i] if ($i -eq 8) { $v = ($v -shl 8) -bor [uint64]$b; $n = 9; break } $v = ($v -shl 7) -bor [uint64]($b -band 0x7F) $n = $i + 1 if (($b -band 0x80) -eq 0) { break } } $Length.Value = $n return $v } function Get-SerialSize([uint64]$Serial) { if ($Serial -eq 0 -or $Serial -eq 8 -or $Serial -eq 9 -or $Serial -eq 10 -or $Serial -eq 11) { return 0 } if ($Serial -eq 1) { return 1 } if ($Serial -eq 2) { return 2 } if ($Serial -eq 3) { return 3 } if ($Serial -eq 4) { return 4 } if ($Serial -eq 5) { return 6 } if ($Serial -eq 6 -or $Serial -eq 7) { return 8 } if (($Serial % 2) -eq 0) { return [int](($Serial - 12) / 2) } return [int](($Serial - 13) / 2) } function Read-SerialValue([byte[]]$Data, [int]$Offset, [uint64]$Serial) { if ($Serial -eq 0 -or $Serial -eq 10 -or $Serial -eq 11) { return $null } if ($Serial -eq 8) { return [int64]0 } if ($Serial -eq 9) { return [int64]1 } $size = Get-SerialSize $Serial if ($Serial -ge 12) { $bytes = Copy-Bytes $Data $Offset $size # An odd serial type is text, an even one is a blob. if (($Serial % 2) -eq 1) { return Get-Utf8String $bytes } return $bytes } if ($Serial -eq 7) { $bytes = Copy-Bytes $Data $Offset 8 [Array]::Reverse($bytes) return [System.BitConverter]::ToDouble($bytes, 0) } # 1..6: a signed big-endian integer. $v = [int64]0 for ($i = 0; $i -lt $size; $i++) { $v = ($v -shl 8) -bor [int64]$Data[$Offset + $i] } if ($size -lt 8) { $signBit = [int64]1 -shl (($size * 8) - 1) if (($v -band $signBit) -ne 0) { $v = $v - ([int64]1 -shl ($size * 8)) } } return $v } function Read-SqliteRecord([byte[]]$Payload) { $len = 0 $headerSize = [int](Read-SqliteVarint $Payload 0 ([ref]$len)) $serials = New-Object 'System.Collections.Generic.List[uint64]' $p = $len while ($p -lt $headerSize) { $n = 0 $s = Read-SqliteVarint $Payload $p ([ref]$n) $serials.Add($s) $p += $n } $values = New-Object 'System.Collections.Generic.List[object]' $p = $headerSize foreach ($s in $serials) { $values.Add((Read-SerialValue $Payload $p $s)) $p += (Get-SerialSize $s) } return , $values.ToArray() } function Get-SqlitePayload([byte[]]$File, [int]$PageSize, [int]$Usable, [int]$PageNo, [int]$CellOffset) { # One table-leaf cell: varint payload length, varint rowid, payload. $p = $CellOffset $n = 0 $payloadLen = [int](Read-SqliteVarint $File $p ([ref]$n)) $p += $n $rowid = [int64](Read-SqliteVarint $File $p ([ref]$n)) $p += $n $maxLocal = $Usable - 35 if ($payloadLen -le $maxLocal) { return , (Copy-Bytes $File $p $payloadLen) } # Local part, then the overflow chain at the end of the cell. $minLocal = [int][math]::Floor((($Usable - 12) * 32) / 255) - 23 $k = $minLocal + (($payloadLen - $minLocal) % ($Usable - 4)) $local = if ($k -le $maxLocal) { $k } else { $minLocal } $out = New-Object System.IO.MemoryStream try { $out.Write($File, $p, $local) $next = [int](Read-BeU32 $File ($p + $local)) $remaining = $payloadLen - $local while ($next -ne 0 -and $remaining -gt 0) { $base = ($next - 1) * $PageSize $take = [math]::Min($remaining, $Usable - 4) $out.Write($File, $base + 4, $take) $remaining -= $take $next = [int](Read-BeU32 $File $base) } if ($remaining -gt 0) { throw 'the overflow chain ended early' } return , $out.ToArray() } finally { $out.Dispose() } } function Read-SqliteTablePages { param( [byte[]]$File, [int]$PageSize, [int]$Usable, [int]$PageNo, [System.Collections.Generic.List[object]]$Out ) $base = ($PageNo - 1) * $PageSize $header = $base if ($PageNo -eq 1) { $header = $base + 100 } if (($header + 12) -gt $File.Length) { throw "page $PageNo is outside the file" } $type = $File[$header] $cellCount = Read-BeU16 $File ($header + 3) $pointers = $header + 8 if ($type -eq 0x05) { # Table interior: children, then the right-most pointer. $right = Read-BeU32 $File ($header + 8) $pointers = $header + 12 for ($i = 0; $i -lt $cellCount; $i++) { $cell = $base + (Read-BeU16 $File ($pointers + 2 * $i)) $child = [int](Read-BeU32 $File $cell) Read-SqliteTablePages -File $File -PageSize $PageSize -Usable $Usable ` -PageNo $child -Out $Out } if ($right -ne 0) { Read-SqliteTablePages -File $File -PageSize $PageSize -Usable $Usable ` -PageNo ([int]$right) -Out $Out } return } if ($type -ne 0x0D) { throw ('unsupported b-tree page type 0x{0:x2}' -f $type) } for ($i = 0; $i -lt $cellCount; $i++) { $cell = $base + (Read-BeU16 $File ($pointers + 2 * $i)) $Out.Add((Get-SqlitePayload $File $PageSize $Usable $PageNo $cell)) } } function Get-SqliteTableColumns([string]$Sql) { # The column names out of `CREATE TABLE x (a TEXT, b INT)`. Only the # first token of each top-level item is a name. $open = $Sql.IndexOf('(') if ($open -lt 0) { return @() } $depth = 0 $end = -1 for ($i = $open; $i -lt $Sql.Length; $i++) { if ($Sql[$i] -eq '(') { $depth++ } elseif ($Sql[$i] -eq ')') { $depth--; if ($depth -eq 0) { $end = $i; break } } } if ($end -lt 0) { return @() } $inside = $Sql.Substring($open + 1, $end - $open - 1) $items = New-Object 'System.Collections.Generic.List[string]' $current = New-Object System.Text.StringBuilder $depth = 0 $quote = [char]0 foreach ($ch in $inside.ToCharArray()) { if ($quote -ne [char]0) { [void]$current.Append($ch) if ($ch -eq $quote) { $quote = [char]0 } continue } if ($ch -eq '"' -or $ch -eq "'" -or $ch -eq '`') { $quote = $ch; [void]$current.Append($ch); continue } if ($ch -eq '(') { $depth++ } elseif ($ch -eq ')') { $depth-- } if ($ch -eq ',' -and $depth -eq 0) { $items.Add($current.ToString()); [void]$current.Clear(); continue } [void]$current.Append($ch) } $items.Add($current.ToString()) $names = New-Object 'System.Collections.Generic.List[string]' foreach ($item in $items) { $trimmed = $item.Trim() if (-not $trimmed) { continue } $first = ($trimmed -split '\s+')[0].Trim('"', "'", '`', '[', ']') if ($first -eq 'CONSTRAINT' -or $first -eq 'PRIMARY' -or $first -eq 'UNIQUE' -or $first -eq 'FOREIGN' -or $first -eq 'CHECK') { continue } $names.Add($first) } return , $names } function Read-SqliteTable([string]$Path, [string]$Table, [string[]]$Expected) { # Every row of one table, as hashtables keyed by column name. $file = Read-FileBytes $Path if ($file.Length -lt 100) { throw 'too short to be a SQLite file' } $magic = [System.Text.Encoding]::ASCII.GetString($file, 0, 16) if ($magic -ne ("SQLite format 3" + [char]0)) { throw 'not a SQLite file' } $pageSize = Read-BeU16 $file 16 if ($pageSize -eq 1) { $pageSize = 65536 } if ($pageSize -lt 512 -or ($pageSize -band ($pageSize - 1)) -ne 0) { throw "implausible page size $pageSize" } $usable = $pageSize - [int]$file[20] $payloads = New-Object 'System.Collections.Generic.List[object]' Read-SqliteTablePages -File $file -PageSize $pageSize -Usable $usable -PageNo 1 -Out $payloads $rootPage = -1 $columns = $null foreach ($payload in $payloads) { $row = Read-SqliteRecord ([byte[]]$payload) if ($row.Length -lt 5) { continue } if ($row[0] -ne 'table' -or $row[1] -ne $Table) { continue } $rootPage = [int]$row[3] $columns = Get-SqliteTableColumns ([string]$row[4]) } if ($rootPage -lt 0) { $names = @() foreach ($payload in $payloads) { $r = Read-SqliteRecord ([byte[]]$payload) if ($r.Length -ge 2 -and $r[0] -eq 'table') { $names += [string]$r[1] } } $shown = if ($names.Count -gt 0) { $names -join ', ' } else { 'none' } throw "the file has no table $Table (tables: $shown)" } if ($null -eq $columns -or $columns.Count -eq 0) { $columns = $Expected } $keyAt = -1 $valueAt = -1 for ($i = 0; $i -lt $columns.Count; $i++) { if ($columns[$i] -ieq '_KEY') { $keyAt = $i } elseif ($columns[$i] -ieq '_VALUE') { $valueAt = $i } } if ($keyAt -lt 0 -or $valueAt -lt 0) { throw "table $Table is not (_KEY,_VALUE): got $($columns -join ', ')" } $rows = New-Object 'System.Collections.Generic.List[object]' $pointer = New-Object 'System.Collections.Generic.List[object]' Read-SqliteTablePages -File $file -PageSize $pageSize -Usable $usable -PageNo $rootPage -Out $pointer foreach ($payload in $pointer) { $row = Read-SqliteRecord ([byte[]]$payload) if ($row.Length -le [math]::Max($keyAt, $valueAt)) { continue } $rows.Add([pscustomobject]@{ PageSize = $pageSize Key = [string]$row[$keyAt] Value = [string]$row[$valueAt] }) } return , $rows } # =============================================== THE KEY SOURCE (the seam) # # APP-SIGNATURE is RSA-PKCS#1-v1.5-SHA1 over APP-REQUEST-ID, made with a # per-install RSA-512 key. The key, the public user id and the device id # all live in the game's own store, one `%LOCALAPPDATA%\GameLib\\auth.db`, # so they are read together from there. # ======================================================================== function Get-GameLibRoot { # `%LOCALAPPDATA%\GameLib` - where the per-install dmm stores live. $local = "$env:LOCALAPPDATA".Trim() if ($local) { return $local } $profile = "$env:USERPROFILE".Trim() if (-not $profile) { $profile = [Environment]::GetFolderPath('UserProfile') } return (Join-Path (Join-Path $profile 'AppData') 'Local') } function Test-PemLooksValid([byte[]]$Pem) { if ($null -eq $Pem -or $Pem.Length -eq 0) { return $false } $text = Get-Utf8String $Pem return ($text.Contains('-----BEGIN') -and $text.Contains('PRIVATE KEY-----')) } function Get-AsPem([string]$Value) { # A path to a PEM file, or the PEM text itself. try { if (Test-Path -LiteralPath $Value -PathType Leaf) { return , (Read-FileBytes $Value) } } catch { } return , (Get-Utf8 $Value) } function Get-PemFromIdentityFile { # `signer_pem` (path or text) in the identity file, if it carries one. $path = Get-IdentityFilePath if (-not $path) { return $null } $doc = $null try { $doc = ConvertFrom-MtcoeJson (Read-FileText $path) } catch { return $null } $value = Get-JProp $doc 'signer_pem' if (-not $value) { return $null } return [pscustomobject]@{ Pem = (Get-AsPem "$value"); Where = "signer_pem in $path" } } function ConvertFrom-StoreRow([string]$Hex) { # One `_VALUE`: lowercase hex of AES-128-CBC/PKCS#7 under AUTHDB_AES_KEY/ # AUTHDB_IV. The same cipher the request body uses, reached with an # explicit IV; the key is 16 bytes (AES-128) rather than the body's 32. return , (ConvertFrom-AesCbc (ConvertFrom-Hex $Hex) $AuthDbAesKey $AuthDbIv) } function Get-ScratchRoot { # Where the throwaway copy of the store goes: the driver's own directory # (never %LOCALAPPDATA%/%APPDATA% and never the game install), which under # a piped one-liner means the temp directory the script itself came from. if ($ScriptRoot -and (Test-Path -LiteralPath $ScriptRoot -PathType Container)) { return $ScriptRoot } return [System.IO.Path]::GetTempPath() } function New-StoreCopy([string]$Path) { # A throwaway copy of the live store - never the store itself. # # The game may hold `auth.db` open, so it is copied first and the copy is # what gets read. The copy is made inside the driver's own directory and # its caller deletes it before returning; nothing outside this machine # ever sees it. $root = Get-ScratchRoot $folder = $null try { $name = 'mtcoe-authdb-' + [System.IO.Path]::GetRandomFileName() $folder = Join-Path $root $name [void](New-Item -ItemType Directory -Path $folder -ErrorAction Stop) } catch { throw (New-UserError ("cannot make a temporary copy of the store in $root`: " + "$($_.Exception.Message)" + [char]10 + ' The driver will not open the live auth.db in place. Pass --out ' + '(or run from a writable directory).')) } $copy = Join-Path $folder ([System.IO.Path]::GetFileName($Path)) $src = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { $dst = [System.IO.File]::Open($copy, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) try { $src.CopyTo($dst) } finally { $dst.Dispose() } } finally { $src.Dispose() } return [pscustomobject]@{ Folder = $folder; Copy = $copy } } function Read-AuthDb([string]$Path) { # Every row of one store, decrypted. It throws so the caller can skip a # store that will not open, is not a store, or does not decrypt. $made = New-StoreCopy $Path try { try { $rows = Read-SqliteTable $made.Copy $AuthDbTable @('_KEY', '_VALUE') } catch { throw "sqlite: $($_.Exception.Message)" } } finally { Remove-Item -LiteralPath $made.Folder -Recurse -Force -ErrorAction SilentlyContinue } if ($rows.Count -eq 0) { throw 'the store has no rows' } $out = @{} foreach ($row in $rows) { if ($row.Value) { try { $out[$row.Key] = ConvertFrom-StoreRow $row.Value } catch { throw "padding: $($_.Exception.Message)" } } } if ($out.Count -eq 0) { throw 'the store has no rows' } return $out } #: Cache of the one store this run chose. The key and both identity values #: MUST come from the same store, so it is picked and decrypted once and every #: source reads the same object. (A hashtable, so the nested functions all see #: the same mutation.) $authDbCache = @{} function Get-AuthDbStore { # Pick the store, decrypt it, and say which one and why. # # Candidates are every `%LOCALAPPDATA%\GameLib\*\auth.db`. The right one # is the account the client is currently signed into, so: # # 1. a store whose `x_uid` equals the id in the client's own # `LocalSaveData\.dat` is preferred - that is the # account the client is signed into right now; # 2. otherwise the most recently written store. # # A store that will not open or will not decrypt is skipped, never fatal, # and the reason names it. The chosen store's reason is carried so # `--whatif` and a run both print it - a wrong pick is visible, not # silent. if ($authDbCache.ContainsKey('Done')) { return [pscustomobject]@{ Store = $authDbCache.Store; Reason = $authDbCache.Reason } } $root = Join-Path (Get-GameLibRoot) $AuthDbGlob $candidates = @() if (Test-Path -LiteralPath $root -PathType Container) { $candidates = @( Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName 'auth.db' } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Sort-Object -Property { $_.ToString() } ) } $readable = New-Object 'System.Collections.Generic.List[object]' $skipped = New-Object 'System.Collections.Generic.List[string]' foreach ($path in $candidates) { $rows = $null try { $rows = Read-AuthDb $path } catch { $skipped.Add("$([System.IO.Path]::GetFileName($path)) did not decrypt ($($_.Exception.Message))") continue } if (-not $rows.ContainsKey($AuthDbRowKey)) { $skipped.Add("$([System.IO.Path]::GetFileName($path)) has no $AuthDbRowKey row") continue } $readable.Add([pscustomobject]@{ Path = $path; Rows = $rows; Why = '' }) } if ($readable.Count -eq 0) { if ($candidates.Count -eq 0) { $reason = "no store at $root" } else { $reason = "none of the $($candidates.Count) store(s) under $(Get-GameLibRoot) was " + 'readable: ' + ($skipped -join '; ') } $authDbCache.Done = $true $authDbCache.Store = $null $authDbCache.Reason = $reason return [pscustomobject]@{ Store = $null; Reason = $reason } } $current = Get-UserIdFromLocalSaveData $chosen = $null if ($current) { foreach ($store in $readable) { $raw = $store.Rows[$AuthDbRowUserId] if ($raw -and ((Get-Utf8String $raw) -eq $current.Id)) { $chosen = $store $chosen.Why = "chosen because its $AuthDbRowUserId matches " + "the client's own $($current.Where)" break } } } if ($null -eq $chosen) { $best = $null $bestTime = [datetime]::MinValue foreach ($store in $readable) { $t = (Get-Item -LiteralPath $store.Path).LastWriteTimeUtc if ($null -eq $best -or $t -gt $bestTime) { $best = $store; $bestTime = $t } } $chosen = $best if ($current) { $chosen.Why = "chosen as the most recent of $($readable.Count) readable " + "store(s); none matched $($current.Where)" } else { $chosen.Why = "chosen as the most recent of $($readable.Count) readable " + "store(s); the client's LocalSaveData named no id to match" } } if ($skipped.Count -gt 0) { $chosen.Why += "; skipped $($skipped.Count) ($($skipped -join '; '))" } $authDbCache.Done = $true $authDbCache.Store = $chosen $authDbCache.Reason = $chosen.Why return [pscustomobject]@{ Store = $chosen; Reason = $chosen.Why } } function Get-PemFromAuthDb { # Read `prv_key` out of the game's own store: the PKCS#8 PEM of the # per-install RSA-512 key, stored as lowercase hex of AES-128-CBC/PKCS#7 # under the fixed AUTHDB_AES_KEY/IV above. Returns the PEM and where it # came from - the description carries the store path, why it was chosen # and a hash prefix of the PEM, never the key itself. $found = Get-AuthDbStore $store = $found.Store if ($null -eq $store) { throw (New-UserError "the game's own store could not be read: $($found.Reason)") } $pem = $store.Rows[$AuthDbRowKey] if (-not (Test-PemLooksValid $pem)) { $len = 0 if ($pem) { $len = $pem.Length } throw (New-UserError ( "the $AuthDbRowKey row of $($store.Path) did not decrypt to a " + "PKCS#8 PEM ($len bytes)." + [char]10 + ' The store key (AUTHDB_AES_KEY/IV) is a constant of the build, so ' + [char]10 + ' this means the store was written by a different build, or the row ' + [char]10 + ' is damaged - not that the store key is per-install.')) } $digest = (Get-Sha256Hex $pem).Substring(0, 8) return [pscustomobject]@{ Pem = $pem Where = "$AuthDbRowKey in $($store.Path); pem sha256 $digest" } } function Get-MissingKeyMessage([string]$Reason) { $indented = $Reason.Replace("`n", "`n ") $beside = if ($ScriptRoot) { Join-Path $ScriptRoot $SignerPemBeside } else { $SignerPemBeside } return ("the APP-SIGNATURE private key was not found." + [char]10 + ' The game keeps it in %LOCALAPPDATA%\GameLib\\auth.db as the' + [char]10 + ' `prv_key` row, and this run could not read it:' + [char]10 + " $indented" + [char]10 + [char]10 + ' Give it the key another way, in one of these three places:' + [char]10 + " 1. the environment variable $SignerPemEnv - either a path to a" + [char]10 + ' PEM file, or the PEM text itself:' + [char]10 + " `$env:$SignerPemEnv = 'C:\path\to\signer.pem'" + [char]10 + " 2. a file called $SignerPemBeside beside this script:" + [char]10 + " $beside" + [char]10 + ' 3. a "signer_pem" entry (a path) in the identity file.' + [char]10 + [char]10 + ' The key is the PKCS#8 ``-----BEGIN PRIVATE KEY-----`` block' + [char]10 + ' (RSA-512) the client uses for APP-SIGNATURE. It is per install, it is' + [char]10 + ' an account credential, and it is not in this repository by design -' + [char]10 + ' see the puller README, "Where the signing key comes from".') } function Get-SigningKeyPem { # THE SEAM: the APP-SIGNATURE private key, and where it came from. # # Order of sources, first hit wins: # 1. `MTCOE_SIGNER_PEM` - a path to a PEM file, or the PEM text itself; # 2. `signer_pem` inside the identity file, if that file names one; # 3. `signer.pem` beside this script; # 4. `Get-PemFromAuthDb` - the game's own store (the normal answer). # # Never a hardcoded literal, and never a value committed to this # repository: the key is per install and is an account credential. $override = "$env:MTCOE_SIGNER_PEM".Trim() if ($override) { $pem = Get-AsPem $override if (-not (Test-PemLooksValid $pem)) { throw (New-UserError ( "$SignerPemEnv does not look like a PEM private key " + "($($pem.Length) bytes, no 'BEGIN ... PRIVATE KEY' header)." + [char]10 + 'Point it at a PKCS#8 PEM file (or paste the PEM itself).')) } return [pscustomobject]@{ Pem = $pem Where = "$SignerPemEnv (hash $((Get-Sha256Hex $pem).Substring(0, 8)))" } } $fromFile = Get-PemFromIdentityFile if ($fromFile -and (Test-PemLooksValid $fromFile.Pem)) { return $fromFile } $root = if ($ScriptRoot) { $ScriptRoot } else { (Get-Location).Path } $beside = Join-Path $root $SignerPemBeside if (Test-Path -LiteralPath $beside -PathType Leaf) { $pem = Read-FileBytes $beside if (Test-PemLooksValid $pem) { return [pscustomobject]@{ Pem = $pem; Where = "$beside" } } } try { return Get-PemFromAuthDb } catch [System.ApplicationException] { throw (New-UserError (Get-MissingKeyMessage $_.Exception.Message)) } } # ------------------------------------------------- minimal ASN.1/DER ------ # # RSA.ImportPkcs8PrivateKey does not exist on Windows PowerShell 5.1 (.NET # Framework's RSA never grew it), so the key is decoded here and handed to # ImportParameters - which exists on both. This is the driver's # `_load_rsa_key`/`_der`/`_der_children` ported. function Read-Der([byte[]]$Data, [int]$Pos, [ref]$Next) { $tag = $Data[$Pos] $Pos++ $len = [int]$Data[$Pos] $Pos++ if (($len -band 0x80) -ne 0) { $n = $len -band 0x7F $len = 0 for ($i = 0; $i -lt $n; $i++) { $len = ($len -shl 8) -bor [int]$Data[$Pos + $i] } $Pos += $n } $Next.Value = $Pos + $len return [pscustomobject]@{ Tag = $tag; Value = (Copy-Bytes $Data $Pos $len) } } function Get-DerChildren([byte[]]$Value) { $out = New-Object 'System.Collections.Generic.List[object]' $pos = 0 while ($pos -lt $Value.Length) { $next = 0 $item = Read-Der $Value $pos ([ref]$next) $out.Add($item) $pos = $next } return , $out } function ConvertFrom-DerInteger([byte[]]$Bytes) { # DER INTEGERs are signed: a leading zero is sign padding and .NET's # RSAParameters wants an unsigned big-endian value. $start = 0 while (($start -lt $Bytes.Length - 1) -and $Bytes[$start] -eq 0) { $start++ } return , (Copy-Bytes $Bytes $start ($Bytes.Length - $start)) } function New-RsaFromPem([byte[]]$Pem) { $body = New-Object System.Text.StringBuilder $text = Get-Utf8String $Pem foreach ($line in ($text -split "`r?`n")) { if (-not $line.StartsWith('-----')) { [void]$body.Append($line.Trim()) } } $der = [Convert]::FromBase64String($body.ToString()) $next = 0 $top = (Read-Der $der 0 ([ref]$next)).Value $ints = New-Object 'System.Collections.Generic.List[byte[]]' foreach ($child in (Get-DerChildren $top)) { if ($child.Tag -eq 0x02) { $ints.Add((ConvertFrom-DerInteger $child.Value)) } } if ($ints.Count -ne 9) { $octet = $null foreach ($child in (Get-DerChildren $top)) { if ($child.Tag -eq 0x04) { $octet = $child.Value; break } } if ($null -eq $octet) { throw (New-UserError 'the signing key is not a PKCS#8 or PKCS#1 private key') } $next = 0 $rsa = (Read-Der $octet 0 ([ref]$next)).Value $ints.Clear() foreach ($child in (Get-DerChildren $rsa)) { if ($child.Tag -eq 0x02) { $ints.Add((ConvertFrom-DerInteger $child.Value)) } } } if ($ints.Count -ne 9) { throw (New-UserError "the signing key has $($ints.Count) INTEGERs, expected 9") } $parms = New-Object System.Security.Cryptography.RSAParameters $parms.Modulus = $ints[1] $parms.Exponent = $ints[2] $parms.D = $ints[3] $parms.P = $ints[4] $parms.Q = $ints[5] $parms.DP = $ints[6] $parms.DQ = $ints[7] $parms.InverseQ = $ints[8] $rsaKey = [System.Security.Cryptography.RSA]::Create() try { $rsaKey.ImportParameters($parms) } catch { $rsaKey.Dispose() throw (New-UserError "the signing key could not be loaded: $($_.Exception.Message)") } return $rsaKey } function Get-SignRequest([string]$RequestId, [byte[]]$Pem) { # base64(RSA-PKCS#1-v1.5-SHA1(request_id)) - the client's APP-SIGNATURE. $key = New-RsaFromPem $Pem try { $sig = $key.SignData((Get-Utf8 $RequestId), [System.Security.Cryptography.HashAlgorithmName]::SHA1, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) return [Convert]::ToBase64String($sig) } finally { $key.Dispose() } } # ==================================================== identity (who we log in as) function Get-IdentityFilePath { $override = "$env:MTCOE_IDENTITY".Trim() if ($override) { return $override } $root = if ($ScriptRoot) { $ScriptRoot } else { (Get-Location).Path } $beside = Join-Path $root $IdentityBeside if (Test-Path -LiteralPath $beside -PathType Leaf) { return $beside } return $null } function Read-IdentityFile { $path = Get-IdentityFilePath if (-not $path) { return @{} } try { $doc = ConvertFrom-MtcoeJson (Read-FileText $path) } catch { throw (New-UserError "cannot read the identity file $path`: $($_.Exception.Message)") } if ($null -eq $doc -or $doc -is [string] -or $doc -is [System.Collections.IEnumerable]) { throw (New-UserError "the identity file $path must be a JSON object") } $out = @{} foreach ($name in @('public_user_id', 'device_id', 'signer_pem')) { $value = Get-JProp $doc $name if ($value) { $out[$name] = "$value" } } return $out } function Read-SavedataUserId { # `savedata.dat` -> `dmm_last_login_id`. UNVERIFIED as a web API id: the # value on disk may be the *DMM* login id. Offered last, and loudly. $root = "$env:USERPROFILE".Trim() if (-not $root) { $root = [Environment]::GetFolderPath('UserProfile') } $title = Join-Path (Join-Path (Join-Path $root 'AppData') 'LocalLow') 'GREE Entertainment' if (-not (Test-Path -LiteralPath $title -PathType Container)) { return $null } $saves = @( Get-ChildItem -LiteralPath $title -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName 'savedata.dat' } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Sort-Object -Property { $_.ToString() } ) foreach ($save in $saves) { try { $doc = ConvertFrom-MtcoeJson (Read-FileText $save) } catch { continue } $keys = Get-JProp $doc 'keys' $values = Get-JProp $doc 'values' if ($null -eq $keys -or $null -eq $values) { continue } if ($keys -is [string] -or $values -is [string]) { continue } $keyList = ConvertTo-ObjectArray $keys $valueList = ConvertTo-ObjectArray $values $index = -1 for ($i = 0; $i -lt $keyList.Count; $i++) { if ("$($keyList[$i])" -eq 'dmm_last_login_id') { $index = $i; break } } if ($index -ge 0 -and $index -lt $valueList.Count) { return [pscustomobject]@{ Id = "$($valueList[$index])"; Where = "$save" } } } return $null } # ------------------------------------------------- MessagePack (save files) -- function Read-MpUint([byte[]]$Data, [int]$Pos, [int]$N) { if (($Pos + $N) -gt $Data.Length) { throw 'truncated MessagePack integer' } $v = [int64]0 for ($i = 0; $i -lt $N; $i++) { $v = ($v -shl 8) -bor [int64]$Data[$Pos + $i] } return [pscustomobject]@{ Value = $v; Next = ($Pos + $N) } } function Read-MpValue([byte[]]$Data, [int]$Pos, [int]$Depth) { # One MessagePack value, from the tiny subset these save files use: # arrays, unsigned integers, nil and UTF-8 strings, and nothing else. if ($Depth -gt 8) { throw 'MessagePack nesting too deep' } if ($Pos -ge $Data.Length) { throw 'truncated MessagePack' } $b = $Data[$Pos] if ($b -le 0x7F) { return [pscustomobject]@{ Value = [int64]$b; Next = ($Pos + 1) } } if ($b -eq 0xC0) { return [pscustomobject]@{ Value = $null; Next = ($Pos + 1) } } if ($b -ge 0xCC -and $b -le 0xCF) { $len = @{ 0xCC = 1; 0xCD = 2; 0xCE = 4; 0xCF = 8 }[[int]$b] $n = Read-MpUint $Data ($Pos + 1) $len return [pscustomobject]@{ Value = $n.Value; Next = $n.Next } } if ($b -ge 0x90 -and $b -le 0x9F) { return Read-MpArray $Data ($Pos + 1) ([int]($b -band 0x0F)) $Depth } if ($b -eq 0xDC -or $b -eq 0xDD) { $count = Read-MpUint $Data ($Pos + 1) $(if ($b -eq 0xDC) { 2 } else { 4 }) return Read-MpArray $Data $count.Next ([int]$count.Value) $Depth } if ($b -ge 0xA0 -and $b -le 0xBF) { return Read-MpString $Data ($Pos + 1) ([int]($b -band 0x1F)) } if ($b -eq 0xD9 -or $b -eq 0xDA -or $b -eq 0xDB) { $len = @{ 0xD9 = 1; 0xDA = 2; 0xDB = 4 }[[int]$b] $n = Read-MpUint $Data ($Pos + 1) $len return Read-MpString $Data $n.Next ([int]$n.Value) } throw ('unsupported MessagePack byte 0x{0:x2}' -f $b) } function Read-MpArray([byte[]]$Data, [int]$Pos, [int]$Count, [int]$Depth) { $items = New-Object 'System.Collections.Generic.List[object]' $p = $Pos for ($i = 0; $i -lt $Count; $i++) { $item = Read-MpValue $Data $p ($Depth + 1) $items.Add($item.Value) $p = $item.Next } return [pscustomobject]@{ Value = $items; Next = $p } } function Read-MpString([byte[]]$Data, [int]$Pos, [int]$N) { if (($Pos + $N) -gt $Data.Length) { throw 'truncated MessagePack string' } return [pscustomobject]@{ Value = (Get-Utf8String (Copy-Bytes $Data $Pos $N)) Next = ($Pos + $N) } } function Get-PublicUserIdFromSave([object]$Root) { # Root is `[Player]`; Player is `[Key(0) PublicUserId, Key(1) Password, # Key(2) EnumAccessPoint.Environment, Key(3) Language]`. Anything else - # a wrong key, a different class, a non-id first value - is rejected. if ($Root -isnot [System.Collections.IEnumerable] -or $Root -is [string]) { return $null } $rootItems = ConvertTo-ObjectArray $Root if ($rootItems.Count -ne 1) { return $null } $player = $rootItems[0] if ($player -isnot [System.Collections.IEnumerable] -or $player -is [string]) { return $null } $items = ConvertTo-ObjectArray $player if ($items.Count -eq 0) { return $null } $id = $items[0] if ($id -isnot [int64] -and $id -isnot [int] -and $id -isnot [uint64]) { return $null } $value = [int64]$id if ($value -lt $UserIdMin -or $value -ge $UserIdMax) { return $null } $env = $null if ($items.Count -gt 2) { $raw = $items[2] if ($raw -is [int64] -or $raw -is [int] -or $raw -is [uint64]) { $env = [int]$raw } } return [pscustomobject]@{ Id = "$value"; Env = $env } } function Get-UserIdFromLocalSaveData { # `LocalSaveData\.dat` -> the web API public user id. # # A small AES-256-CBC/PKCS7 MessagePack blob under the per-build literals # LOCAL_SAVE_DATA_KEY/IV. The payload identifies itself: its third value # is the access point the file is named for, so a name is never assumed - # the whole directory is scanned and a file is believed only if it # decrypts, unpads, parses, and yields a sane PublicUserId. This is *not* # `savedata.dat`: that one holds the DMM viewer id. $root = "$env:USERPROFILE".Trim() if (-not $root) { $root = [Environment]::GetFolderPath('UserProfile') } $saveDir = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $root 'AppData') 'LocalLow') ` 'GREE Entertainment') $SaveDirTitle) 'LocalSaveData' if (-not (Test-Path -LiteralPath $saveDir -PathType Container)) { return $null } $fallback = $null $files = @( Get-ChildItem -LiteralPath $saveDir -Filter '*.dat' -File -ErrorAction SilentlyContinue | Sort-Object -Property { $_.FullName } ) foreach ($file in $files) { $value = $null try { $plain = ConvertFrom-AesCbc (Read-FileBytes $file.FullName) ` $LocalSaveDataKey $LocalSaveDataIv $decoded = Read-MpValue $plain 0 0 $value = Get-PublicUserIdFromSave $decoded.Value } catch { continue } if ($null -eq $value) { continue } $where = "PublicUserId in LocalSaveData/$($file.Name)" if ($null -ne $value.Env -and $AccessPoints.ContainsKey($value.Env)) { return [pscustomobject]@{ Id = $value.Id; Where = "$where (access point $($AccessPoints[$value.Env]))" } } if ($null -eq $fallback) { $fallback = [pscustomobject]@{ Id = $value.Id; Where = "$where (unknown access point $($value.Env))" } } } return $fallback } function Get-IdentityFromAuthDb { # `x_uid` and `uuid` out of the same store the signing key comes from. # # One function on purpose: `prv_key`, `x_uid` and `uuid` are one # account's set, written together, and the key is bound to the account - # so taking the key from one store and the id from another would produce # a pair that cannot authenticate. $found = Get-AuthDbStore $store = $found.Store if ($null -eq $store) { return @{} } $out = @{} $raw = $store.Rows[$AuthDbRowUserId] if ($raw) { $userId = Get-Utf8String $raw $numeric = $userId.Length -gt 0 foreach ($ch in $userId.ToCharArray()) { if ($ch -lt '0' -or $ch -gt '9') { $numeric = $false; break } } if ($numeric) { $value = [int64]$userId if ($value -ge $UserIdMin -and $value -lt $UserIdMax) { $out['user_id'] = $userId $out['user_id_source'] = "$AuthDbRowUserId in $($store.Path) ($($store.Why))" } } } $raw = $store.Rows[$AuthDbRowDeviceId] if ($raw) { $out['device_id'] = Get-Utf8String $raw $out['device_id_source'] = "$AuthDbRowDeviceId in $($store.Path)" } return $out } function Resolve-Identity([object]$Settings) { # The ordered identity sources. Returns values plus how each was found. # # public user id `--user-id` -> `MTCOE_USER_ID` -> `auth.db` `x_uid` -> # the identity file -> `LocalSaveData\.dat` -> # `savedata.dat` `dmm_last_login_id` (unverified, warns) # device id `--device-id` -> `MTCOE_DEVICE_ID` -> `auth.db` `uuid` -> # the identity file $fileDoc = Read-IdentityFile $filePath = Get-IdentityFilePath $out = @{} $fromStore = Get-IdentityFromAuthDb if ($Settings.UserId) { $out['user_id'] = $Settings.UserId $out['user_id_source'] = '--user-id' } elseif ("$env:MTCOE_USER_ID".Trim()) { $out['user_id'] = "$env:MTCOE_USER_ID".Trim() $out['user_id_source'] = $UserIdEnv } elseif ($fromStore.ContainsKey('user_id')) { $out['user_id'] = $fromStore['user_id'] $out['user_id_source'] = $fromStore['user_id_source'] } elseif ($fileDoc.ContainsKey('public_user_id')) { $out['user_id'] = $fileDoc['public_user_id'] $out['user_id_source'] = "public_user_id in $filePath" } else { $found = Get-UserIdFromLocalSaveData if ($found) { $out['user_id'] = $found.Id $out['user_id_source'] = $found.Where } else { $found = Read-SavedataUserId if ($found) { $out['user_id'] = $found.Id $out['user_id_source'] = "dmm_last_login_id in $($found.Where)" $out['user_id_unverified'] = '1' } } } if ($Settings.DeviceId) { $out['device_id'] = $Settings.DeviceId $out['device_id_source'] = '--device-id' } elseif ("$env:MTCOE_DEVICE_ID".Trim()) { $out['device_id'] = "$env:MTCOE_DEVICE_ID".Trim() $out['device_id_source'] = $DeviceIdEnv } elseif ($fromStore.ContainsKey('device_id')) { $out['device_id'] = $fromStore['device_id'] $out['device_id_source'] = $fromStore['device_id_source'] } elseif ($fileDoc.ContainsKey('device_id')) { $out['device_id'] = $fileDoc['device_id'] $out['device_id_source'] = "device_id in $filePath" } return $out } function Get-IdentityProblems([hashtable]$Identity) { $problems = New-Object 'System.Collections.Generic.List[string]' $root = if ($ScriptRoot) { $ScriptRoot } else { (Get-Location).Path } if (-not $Identity.ContainsKey('user_id')) { $problems.Add( 'the web API public user id (APP-PUBLIC-USER-ID) was not found.' + [char]10 + ' It is normally read as `x_uid` from the game''s own store,' + [char]10 + ' %LOCALAPPDATA%\GameLib\\auth.db, and otherwise from the' + [char]10 + ' client''s own LocalSaveData\.dat (' + [char]10 + (' %USERPROFILE%\AppData\LocalLow\GREE Entertainment\' + $SaveDirTitle + ').') + [char]10 + ' If both reads failed, put it in one of these places:' + [char]10 + (' * a JSON file beside this script, ' + (Join-Path $root $IdentityBeside) + ':') + [char]10 + ' { "public_user_id": "...", "device_id": "..." }' + [char]10 + " * or point $IdentityEnv at such a file;" + [char]10 + ' * or pass --user-id.' + [char]10 + " It is NOT savedata.dat's dmm_last_login_id: that is the DMM viewer" + [char]10 + ' id, a different number (LOCAL_IDENTITY.md).') } if (-not $Identity.ContainsKey('device_id')) { $problems.Add( 'the APP-DEVICE-ID was not found.' + [char]10 + ' It is the game''s own device uuid, read as `uuid` from the same' + [char]10 + ' %LOCALAPPDATA%\GameLib\\auth.db store as the signing key. If' + [char]10 + ' that store could not be read, put it next to public_user_id in' + [char]10 + " $(Join-Path $root $IdentityBeside) (or pass --device-id / set $DeviceIdEnv).") } return , $problems } # ============================================================ the game version function Get-SemverCandidates([byte[]]$Blob) { # Length-prefixed (int32 LE) ASCII `x.y.z` strings, NUL-terminated. $out = New-Object 'System.Collections.Generic.List[string]' $i = 4 while ($i -lt $Blob.Length) { $n = [int]$Blob[$i - 4] $b1 = $Blob[$i - 3] $b2 = $Blob[$i - 2] $b3 = $Blob[$i - 1] if ($n -ge 3 -and $b1 -eq 0 -and $b2 -eq 0 -and $b3 -eq 0) { if (($i + $n) -le $Blob.Length) { $ok = $true for ($k = 0; $k -lt $n; $k++) { $c = $Blob[$i + $k] if (-not ((($c -ge 0x30) -and ($c -le 0x39)) -or $c -eq 0x2E)) { $ok = $false; break } } if ($ok) { $nul = ($i + $n) -ge $Blob.Length if (-not $nul) { $nul = ($Blob[$i + $n] -eq 0) } if ($nul) { $out.Add([System.Text.Encoding]::ASCII.GetString($Blob, $i, $n)) $i += $n + 1 continue } } } } $i++ } return , $out } function Read-AppVerFromInstall([string]$Override) { # APP-APP-VER, read from the installed client rather than hardcoded. # # `PlayerSettings.bundleVersion` (what `Application.version` returns, and # what the client puts in this header - "1.2.0" in the live capture) is a # length-prefixed ASCII string inside `_Data/globalgamemanagers`. # The reader takes the LAST length-prefixed `x.y.z` in the first 64 KiB, # which is what that field is. It is a heuristic over a Unity binary, so # a wrong value is refused by the service with error 4. if ($Override) { return [pscustomobject]@{ Ver = $Override; Source = '--app-ver' } } $envVer = "$env:MTCOE_APP_VER".Trim() if ($envVer) { return [pscustomobject]@{ Ver = $envVer; Source = $AppVerEnv } } $roots = New-Object 'System.Collections.Generic.List[string]' if ("$env:MTCOE_GAME_DIR".Trim()) { $roots.Add("$env:MTCOE_GAME_DIR".Trim()) } # The driver's own default lookup path, kept for parity with it. $roots.Add('D:\Games\mushoku_coe_cl') $programFiles = "$env:ProgramFiles" if (-not $programFiles) { $programFiles = 'C:\Program Files' } $roots.Add((Join-Path $programFiles 'mushoku_coe_cl')) foreach ($root in $roots) { if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue } $dataDirs = @( Get-ChildItem -LiteralPath $root -Directory -Filter '*_Data' -ErrorAction SilentlyContinue | Sort-Object -Property { $_.FullName } ) foreach ($dataDir in $dataDirs) { $ggm = Join-Path $dataDir.FullName 'globalgamemanagers' if (-not (Test-Path -LiteralPath $ggm -PathType Leaf)) { continue } $blob = $null try { $blob = Read-FileBytes $ggm } catch { continue } if ($blob.Length -gt 65536) { $blob = Copy-Bytes $blob 0 65536 } $found = $null foreach ($candidate in (Get-SemverCandidates $blob)) { if (($candidate.Split('.')).Count -eq 3) { $found = $candidate } } if ($found) { return [pscustomobject]@{ Ver = $found; Source = "$ggm" } } } } return [pscustomobject]@{ Ver = $FallbackAppVer; Source = 'built-in fallback (game install not found)' } } # ============================================================== http session function New-ApiSession { param([string]$PublicUserId, [string]$DeviceId, [byte[]]$SignerPem, [bool]$VerboseLog) return [pscustomobject]@{ PublicUserId = $PublicUserId DeviceId = $DeviceId SignerPem = $SignerPem Verbose = $VerboseLog Cookie = $null ResourceId = $ResourceIdPlaceholder AppVer = $FallbackAppVer } } function Get-ApiHeaders([object]$Session, [string]$RequestId) { $h = [ordered]@{ 'Content-Type' = 'application/protobuf' 'APP-ENCRYPT' = '1' 'APP-REQUEST-ID' = $RequestId 'APP-APP-VER' = $Session.AppVer 'APP-RESOURCE-ID' = $Session.ResourceId 'APP-DEVICE-ID' = $Session.DeviceId 'APP-PUBLIC-USER-ID' = $Session.PublicUserId 'APP-SIGNATURE' = (Get-SignRequest $RequestId $Session.SignerPem) } foreach ($k in $Stencil.Keys) { $h[$k] = $Stencil[$k] } if ($Session.Cookie) { $h['Cookie'] = $Session.Cookie } return $h } function Get-TransportDetail([object]$Exception) { # .NET's outer message for a failed send is "An error occurred while sending # the request"; what actually went wrong is on the inner exception (DNS, a # refused connection, a TLS handshake). Naming it is the difference between # a usable error and a shrug. $parts = New-Object 'System.Collections.Generic.List[string]' $parts.Add($Exception.Message) $inner = $Exception.InnerException if ($null -ne $inner) { $parts.Add("$($inner.GetType().Name): $($inner.Message)") } return ($parts -join ' <- ') } function Write-ApiCallLog([object]$Result) { # The driver's per-call line. It is returned by Invoke-ApiCall rather than # printed there, because a function that writes to the pipeline cannot also # have a return value. if ($Result.Log) { Write-Line $Result.Log } } function Invoke-ApiCall { param([object]$Session, [string]$Path, [byte[]]$Plain, [int]$Timeout = 30) $rid = New-RequestId $body = Get-Ascii ([Convert]::ToBase64String((ConvertTo-BodyCipher $Plain $rid))) $headers = Get-ApiHeaders $Session $rid $request = $null try { $request = [System.Net.WebRequest]::Create($ApiHost + $Path) } catch { throw (New-UserError "$Path`: transport error $($_.Exception.GetType().Name): $($_.Exception.Message)") } $request.Method = 'POST' $request.ContentType = 'application/protobuf' $request.ContentLength = $body.Length $request.Timeout = $Timeout * 1000 $request.ReadWriteTimeout = $Timeout * 1000 # The one header that is not in the client's block: the reference driver # has only ever been exercised with urllib's default agent, and some # fronts reject a request with no User-Agent at all. $request.UserAgent = 'Python-urllib/3.11' foreach ($k in $headers.Keys) { if ($k -eq 'Content-Type') { continue } $request.Headers[$k] = $headers[$k] } $response = $null try { $stream = $request.GetRequestStream() try { $stream.Write($body, 0, $body.Length) } finally { $stream.Dispose() } $response = $request.GetResponse() } catch [System.Net.WebException] { # A 4xx/5xx is not a failure here: the service's own error body is # encrypted with the same key and is what says *why*. $response = $_.Exception.Response if ($null -eq $response) { throw (New-UserError "$Path`: transport error $($_.Exception.GetType().Name): $(Get-TransportDetail $_.Exception)") } } catch { throw (New-UserError "$Path`: transport error $($_.Exception.GetType().Name): $(Get-TransportDetail $_.Exception)") } $status = 0 $raw = New-Object byte[] 0 $setCookie = $null try { $status = [int]$response.StatusCode $values = $response.Headers.GetValues('Set-Cookie') if ($values -and $values.Count -gt 0) { $setCookie = $values[$values.Count - 1] } $rs = $response.GetResponseStream() try { $ms = New-Object System.IO.MemoryStream try { $rs.CopyTo($ms); $raw = $ms.ToArray() } finally { $ms.Dispose() } } finally { $rs.Dispose() } } finally { $response.Dispose() } # The service mints a fresh PHPSESSID on nearly every reply; it is picked # up here and never printed. if ($setCookie) { $Session.Cookie = $setCookie.Split(';')[0] } $plainReply = $null if ($raw.Length -gt 0) { try { $plainReply = ConvertFrom-BodyCipher ([Convert]::FromBase64String( [System.Text.Encoding]::ASCII.GetString($raw))) $rid } catch { throw (New-UserError "$Path`: the reply ($($raw.Length) bytes) did not decrypt: $($_.Exception.Message)") } } $replyLen = 0 if ($plainReply) { $replyLen = $plainReply.Length } $log = '' if ($Session.Verbose) { $log = " $Path`: HTTP $status reply ${replyLen}B" } return [pscustomobject]@{ Status = $status; Reply = $plainReply; Log = $log } } function Get-LoginFailureMessage([int]$Status, [byte[]]$Reply) { $code = Get-ErrorCode $Reply $hint = '' if ($code -eq 1) { $hint = 'error 1 is the service''s catch-all. The two things that produce it' + [char]10 + ' here are a wrong APP-PUBLIC-USER-ID and a signing key that no' + [char]10 + ' longer matches the install - see the signing key seam.' } elseif ($code -eq 4) { $hint = 'error 4 (new version): APP-APP-VER is stale. Pass --app-ver, or the' + [char]10 + ' game install this script read it from is not the updated one.' } elseif ($code -eq 5) { $hint = 'error 5 (new resource released): APP-RESOURCE-ID is stale. It is read' + [char]10 + ' from /api/version/resource on every run, so this means that call' + [char]10 + ' returned an older roll than the login wants.' } $lines = New-Object 'System.Collections.Generic.List[string]' $head = "/api/login/index answered HTTP $Status" if ($null -ne $code) { $head += " (service error $code)" } $lines.Add($head) if ($hint) { $lines.Add(' ' + $hint) } if ($Reply -and $Reply.Length -gt 0) { $lines.Add(' the reply, field by field (the service echoes no credential):') foreach ($line in (Get-Walk $Reply 0)) { $lines.Add(" $line") } } $lines.Add(' Nothing was pulled. If the game client is open, it has just been logged out.') return ($lines -join [char]10) } function Get-DeviceFingerprint([object]$Session) { # Field 7 of RequestLoginIndex. Shape from the measurement; the numeric # values are placeholders (webapi_login's docstring says the server reads # the device already recorded on the account, not this). # # Built with the same json.dumps shapes the driver uses: the outer object # is compact, the nested `os` value is a JSON string with default # separators - so it is spaced, and it is what makes the field 736 bytes. $osJson = ConvertTo-MtcoeJson -Value ([ordered]@{ operatingSystem = $Stencil['APP-OS-VERSION'] androidVersion = '' apiLevel = 0 }) -Ascii $true -Spaced $true $fingerprint = [ordered]@{ screen = [ordered]@{ width = 1920; height = 1080; dpi = 420.0 refreshRate = 60.0; orientation = 'LandscapeLeft' } device = [ordered]@{ brand = 'ASUS'; manufacturer = 'ASUS'; model = $Stencil['APP-DEVICE-MODEL'] deviceName = ''; deviceType = ''; cpuType = 'X64'; cpuCoreCount = 8 cpuFrequencyMHz = 2457; totalRamMB = 7717 } gpu = [ordered]@{ name = 'NVIDIA GeForce RTX 3060'; deviceId = 84148225; vendor = 'NVIDIA' vendorId = 4318; deviceType = 'D3D11'; version = 'D3D11'; memoryMB = 3877 shaderLevel = 45; maxTextureSize = 16384; supportedRenderTargetCount = 8 } memory = [ordered]@{ availableBytes = 4169854976; availableMB = 3976.68 } storage = [ordered]@{ availableBytes = 108345090048; availableGB = 100.9 } os = $osJson } return (ConvertTo-MtcoeJson -Value $fingerprint -Ascii $true) } function Invoke-Login([object]$Session) { # The 1.2.0 pre-login handshake and `RequestLoginIndex`. # # /api/feature/device-feature is the client's own pre-login call and it # names the device the client is running on. It is sent with the # account's ALREADY RECORDED device id (never a fresh one), so it records # nothing new - an account that has this device already, which is the case # by construction, sees no change. Do not "simplify" this by generating a # device id: that is a write to the account, and the puller does not write. $inner = New-PbBytesField 1 (Get-Utf8 $Session.DeviceId) foreach ($path in @('/api/version/index', '/api/feature/device-feature', '/api/maintenance/index')) { if ($path.EndsWith('device-feature')) { # The driver sends pb_bytes(1, pb_bytes(1, device id)) here - the # inner message is wrapped, and flattening it changes the request. $plain = Join-Bytes @((New-PbBytesField 1 $inner), (New-PbBytesField 4 (Get-Utf8 (Get-JstNow))), (New-PbBytesField 5 (New-Object byte[] 0))) } else { $plain = New-Trailer $null } $result = Invoke-ApiCall $Session $path $plain Write-ApiCallLog $result if ($result.Status -ne 200) { throw (New-UserError ("$path answered HTTP $($result.Status) during the " + "client's own pre-login sequence")) } } $result = Invoke-ApiCall $Session '/api/login/nonce' (New-Trailer $null) Write-ApiCallLog $result $nonce = '' if ($result.Reply -and $result.Reply.Length -gt 0) { foreach ($payload in (Get-PbPayloads $result.Reply 1)) { $one = Get-PbOne $payload if ($one.ContainsKey(1)) { if ($one[1] -is [byte[]]) { $nonce = Get-Utf8String $one[1] } else { $nonce = "$($one[1])" } } if ($nonce) { break } } if (-not $nonce) { $nonce = Get-First64Hex $result.Reply } } if ($result.Status -ne 200 -or -not $nonce) { throw (New-UserError ("/api/login/nonce answered HTTP $($result.Status) without a " + 'usable nonce; the service may be in maintenance')) } $hmac = ConvertTo-HexLower (Get-HmacSha512 $LoginHmacKey (Get-Utf8 $nonce)) $bodyInner = Join-Bytes @( ([byte[]]@(0x08)) # field 1, varint (ConvertTo-VarintBytes ([uint64][int64]$Session.PublicUserId)) (New-PbBytesField 4 (Get-Utf8 (Get-JstNow))) (New-PbBytesField 5 (Get-Utf8 $nonce)) (New-PbBytesField 6 (Get-Utf8 $hmac)) (New-PbBytesField 7 (Get-Utf8 (Get-DeviceFingerprint $Session))) ) $plain = Join-Bytes @($(New-PbBytesField 1 $bodyInner), (New-PbBytesField 4 (Get-Utf8 (Get-JstNow))), (New-PbBytesField 5 (New-Object byte[] 0))) $result = Invoke-ApiCall $Session '/api/login/index' $plain Write-ApiCallLog $result if ($result.Status -ne 200) { throw (New-UserError (Get-LoginFailureMessage $result.Status $result.Reply)) } } function Get-ResourceRoll([object]$Session) { # `/api/version/resource` -> the live APP-RESOURCE-ID. # # Returns the roll *and* the per-call log line: this function has a return # value, so it cannot write to the pipeline itself (the caller would end up # with the log text glued into the roll). $result = Invoke-ApiCall $Session '/api/version/resource' (New-Trailer $null) if ($result.Status -ne 200 -or -not $result.Reply -or $result.Reply.Length -eq 0) { throw (New-UserError ("/api/version/resource answered HTTP $($result.Status); cannot " + 'determine the current content roll.' + [char]10 + ' Logging in with a stale roll is refused (error 5), so this is ' + 'fatal rather than a guess.')) } $roll = '' foreach ($payload in (Get-PbPayloads $result.Reply 1)) { $one = Get-PbOne $payload if ($one.ContainsKey(2) -and $one[2] -is [byte[]]) { $roll = Get-Utf8String $one[2] } } if (-not $roll) { throw (New-UserError '/api/version/resource answered 200 without a resource id') } return [pscustomobject]@{ Roll = $roll; Log = $result.Log } } function Get-ServerStamp([byte[]]$Reply) { # Envelope field 4: `YYYY-MM-DD HH:MM:SS+09:00`, the server's own clock. foreach ($payload in (Get-PbPayloads $Reply 4)) { return (Get-Utf8String $payload) } return '' } # ================================================================== the pull function New-Artifact { param([byte[]]$Reply, [string]$ResourceId, [string]$AppVer, [int]$Days, [string]$PulledAt, [string]$ResponseSha256) # The decrypted history reply -> the version-2 artifact (docs/ARTIFACT.md). $slots = Get-EntitySlots $Reply $execRows = New-Object 'System.Collections.Generic.List[object]' if ($slots.ContainsKey($SlotGachaExec)) { $execRows = $slots[$SlotGachaExec] } $resultRows = New-Object 'System.Collections.Generic.List[object]' if ($slots.ContainsKey($SlotGachaResult)) { $resultRows = $slots[$SlotGachaResult] } $execs = New-Object 'System.Collections.Generic.List[object]' foreach ($row in $execRows) { $eid = [int64](Get-PbNumber $row 1 0) $at = $null if ($row.ContainsKey(8) -and $row[8] -is [byte[]]) { $at = [byte[]]$row[8] } if ($null -eq $at -or $at.Length -eq 0) { throw (New-UserError ("UGachaExec $eid carries no CreateDateStr; " + 'refusing to write a ledger with an unknown time')) } $execs.Add([ordered]@{ id = $eid at = Get-Utf8String $at banner = [int64](Get-PbNumber $row 3 0) button = [int64](Get-PbNumber $row 4 0) cost = [ordered]@{ category = [int64](Get-PbNumber $row 5 0) contentId = [int64](Get-PbNumber $row 6 0) num = [int64](Get-PbNumber $row 7 0) } }) } $execIds = New-Object 'System.Collections.Generic.HashSet[int64]' foreach ($e in $execs) { [void]$execIds.Add([int64]$e['id']) } if ($execIds.Count -ne $execs.Count) { throw (New-UserError ('the reply carries duplicate UGachaExec ids; refusing to ' + 'write a ledger with two rows for one pull')) } $results = New-Object 'System.Collections.Generic.List[object]' $orphans = New-Object 'System.Collections.Generic.List[int64]' $seq = 0 foreach ($row in $resultRows) { $seq++ $execId = [int64](Get-PbNumber $row 2 0) if (-not $execIds.Contains($execId)) { $orphans.Add([int64](Get-PbNumber $row 1 0)) } $results.Add([ordered]@{ id = [int64](Get-PbNumber $row 1 0) exec = $execId seq = [int64]$seq banner = [int64](Get-PbNumber $row 4 0) button = [int64](Get-PbNumber $row 5 0) step = [int64](Get-PbNumber $row 6 0) rateId = [int64](Get-PbNumber $row 7 0) detailId = [int64](Get-PbNumber $row 8 0) item = [ordered]@{ category = [int64](Get-PbNumber $row 9 0) contentId = [int64](Get-PbNumber $row 10 0) num = [int64](Get-PbNumber $row 11 1) } emission = [int64](Get-PbNumber $row 15 0) converted = ([int64](Get-PbNumber $row 12 0) -eq 1) }) } if ($orphans.Count -gt 0) { $first = @($orphans | Select-Object -First 5) throw (New-UserError ( "$($orphans.Count) of $($results.Count) results reference an exec that is " + "not in the same reply (first ids: $($first -join ', '))." + [char]10 + ' Rule 3 of docs/ARTIFACT.md rejects that, so this would not import.' + [char]10 + ' Nothing was written: this is a service inconsistency worth looking ' + [char]10 + 'at, not something to patch over.')) } $ids = New-Object 'System.Collections.Generic.HashSet[int64]' foreach ($r in $results) { [void]$ids.Add([int64]$r['id']) } if ($ids.Count -ne $results.Count) { throw (New-UserError ('the reply carries duplicate UGachaResult ids; refusing to ' + 'write a ledger whose ids collide')) } return [ordered]@{ format = 'mtcoe-wishes' version = 2 pulled_at = $PulledAt puller = $PullerName game = [ordered]@{ app_ver = $AppVer; resource_id = $ResourceId } window = [ordered]@{ requested_days = [int64]$Days } source = [ordered]@{ endpoint = $HistoryEndpoint; response_sha256 = $ResponseSha256 } execs = $execs results = $results } } function Get-PbNumber([hashtable]$Row, [int]$Field, [int64]$Default) { if (-not $Row.ContainsKey($Field)) { return $Default } $value = $Row[$Field] if ($value -is [uint64] -or $value -is [int64] -or $value -is [int]) { return [int64]$value } return $Default } function Get-HistoryRequest([int]$Days) { # `{ 1: RequestGachaHistory{ 2: Begin }, 4: date, 5: "" }` - the client's own. return , (New-Trailer (New-PbBytesField 1 (New-PbVarintField 2 ([uint64]$Days)))) } function Get-LocalIso { return [System.DateTimeOffset]::Now.ToString('yyyy-MM-ddTHH:mm:sszzz', $Invariant) } function Invoke-Pull([object]$Session, [int]$Days) { # Returns the artifact, its digest and the per-call log line - see # Get-ResourceRoll for why the log is handed back instead of printed. $result = Invoke-ApiCall $Session $HistoryEndpoint (Get-HistoryRequest $Days) if ($result.Status -ne 200 -or -not $result.Reply -or $result.Reply.Length -eq 0) { $code = Get-ErrorCode $result.Reply $message = "$HistoryEndpoint answered HTTP $($result.Status)" if ($null -ne $code) { $message += " (service error $code)" } throw (New-UserError $message) } $digest = Get-Sha256Hex $result.Reply $stamp = Get-ServerStamp $result.Reply if (-not $stamp) { $stamp = Get-LocalIso } $pulledAt = $stamp $space = $stamp.IndexOf(' ') if ($space -ge 0) { $pulledAt = $stamp.Substring(0, $space) + 'T' + $stamp.Substring($space + 1) } $artifact = New-Artifact -Reply $result.Reply -ResourceId $Session.ResourceId ` -AppVer $Session.AppVer -Days $Days -PulledAt $pulledAt -ResponseSha256 $digest return [pscustomobject]@{ Artifact = $artifact; Digest = $digest; Log = $result.Log } } # ================================================================== reporting function Format-Mask([string]$Value) { # Show a value is there without printing it: `ab...yz (len 11)`. if ($Value.Length -lt 6) { return "*** (len $($Value.Length))" } return "$($Value.Substring(0, 2))$Ellipsis$($Value.Substring($Value.Length - 2)) (len $($Value.Length))" } function Get-ArtifactFilename { return 'wishes-' + [System.DateTime]::Now.ToString('yyyyMMdd-HHmmss', $Invariant) + '.json' } function Get-OutDirDefault { # Where the artifact goes: beside this script, as docs/ARTIFACT.md says. # # Under the one-liner (`irm ... | iex`) the script has no directory of its # own, so that is the one case where it lands in the temp directory (which # is where the fetched script itself lives) rather than in whatever folder # the player happened to be standing in. `--out` (or `MTCOE_OUT`) puts it # somewhere the player chooses instead. $override = "$env:MTCOE_OUT".Trim() if ($override) { return $override } if ($ScriptRoot) { return $ScriptRoot } return (Join-Path ([System.IO.Path]::GetTempPath()) 'mtcoe-draw-history') } function Write-StoreReport { # Say which auth.db store this run uses and why - before anything fails. # # The store is the source of the signing key and of the identity, so which # one was picked has to be visible: a wrong pick (the other account's # store) is a readable sentence, not a silent failure. Names the path and # the reason, never a value from it. $found = Get-AuthDbStore if ($null -ne $found.Store) { Write-Line " auth.db store $($found.Store.Path)" Write-Line " $($found.Store.Why)" } else { Write-Line " auth.db store none - $($found.Reason)" } } function Write-IdentityReport([hashtable]$Identity) { # Say which source each identity value came from - before anything fails. if ($Identity.ContainsKey('user_id')) { Write-Line " public user id $(Format-Mask $Identity['user_id']) from $($Identity['user_id_source'])" } else { Write-Line ' public user id NOT FOUND' } if ($Identity.ContainsKey('device_id')) { Write-Line " device id $(Format-Mask $Identity['device_id']) from $($Identity['device_id_source'])" } else { Write-Line ' device id NOT FOUND' } } function Write-Plan([object]$Settings, [hashtable]$Identity, [hashtable]$State) { # --whatif: the whole plan, nothing on the wire. $keyState = 'MISSING' try { $key = Get-SigningKeyPem $keyState = "found ($($key.Where), $($key.Pem.Length) bytes)" } catch [System.ApplicationException] { $first = ($_.Exception.Message -split "`r?`n")[0] $keyState = "MISSING - $first" } Write-Line 'PLAN (--whatif: nothing is sent, nothing is written)' Write-Line " puller $PullerName" Write-StoreReport Write-Line " signing key $keyState" if ($Identity.ContainsKey('user_id')) { Write-Line " public user id $(Format-Mask $Identity['user_id']) from $($Identity['user_id_source'])" } else { Write-Line ' public user id MISSING' } if ($Identity.ContainsKey('device_id')) { Write-Line " device id $(Format-Mask $Identity['device_id']) from $($Identity['device_id_source'])" } else { Write-Line ' device id MISSING' } $appVer = Read-AppVerFromInstall $Settings.AppVer Write-Line " APP-APP-VER $($appVer.Ver) (from $($appVer.Source))" Write-Line " window Begin = $($Settings.Days) days back, End = now" Write-Line ' network, in this order, all POST + the standard header block:' Write-Line ' 1. /api/version/resource the live APP-RESOURCE-ID' Write-Line " 2. /api/version/index the client's pre-login sequence" Write-Line ' 3. /api/feature/device-feature' Write-Line ' 4. /api/maintenance/index' Write-Line ' 5. /api/login/nonce the login itself' Write-Line ' 6. /api/login/index' Write-Line " 7. $HistoryEndpoint READ ONLY" Write-Line ' no endpoint that rolls, buys, spends or saves is ever called.' Write-Line " output $(Join-Path $Settings.Out (Get-ArtifactFilename))" Write-Line " site link $($Settings.SiteUrl)?d=$Ellipsis (printed on a real run)" Write-Line '' Write-Line " WARNING: step 6 takes the account's only session. If the game client" Write-Line ' is running, this logs it out. Close the game first.' foreach ($problem in (Get-IdentityProblems $Identity)) { Write-Line " NOT READY: $problem" } if ($keyState -eq 'MISSING') { Write-Line ' NOT READY: the APP-SIGNATURE key is missing - see the message a' Write-Line ' real run prints.' } $State.Code = 0 } function Invoke-PullRun([object]$Settings, [hashtable]$Identity, [hashtable]$State) { Write-StoreReport Write-IdentityReport $Identity $problems = Get-IdentityProblems $Identity if ($problems.Count -gt 0) { throw (New-UserError ($problems -join [char]10)) } if ($Identity.ContainsKey('user_id_unverified')) { $root = if ($ScriptRoot) { $ScriptRoot } else { (Get-Location).Path } Write-Line ' ! the user id came from savedata.dat, which is NOT verified to be' Write-Line ' the web API public user id (LOCAL_IDENTITY.md section 2). If the' Write-Line ' login below fails with error 1, pass --user-id or put the real id' Write-Line " in $(Join-Path $root $IdentityBeside)." } $key = Get-SigningKeyPem $appVer = Read-AppVerFromInstall $Settings.AppVer Write-Line " signing key $($key.Where)" Write-Line " APP-APP-VER $($appVer.Ver) (from $($appVer.Source))" Write-Line '' Write-Line " WARNING: logging in takes the account's only session. A game client" Write-Line ' that is open right now will be logged out. (The game is closed?)' Write-Line '' $session = New-ApiSession -PublicUserId $Identity['user_id'] ` -DeviceId $Identity['device_id'] -SignerPem $key.Pem -Verbose (-not $Settings.Quiet) $session.AppVer = $appVer.Ver Write-Line ' 1. the live content roll:' $roll = Get-ResourceRoll $session Write-ApiCallLog $roll $session.ResourceId = $roll.Roll Write-Line " APP-RESOURCE-ID $($session.ResourceId)" Write-Line ' 2. login:' Invoke-Login $session Write-Line ' logged in (session cookie held in memory, never printed)' Write-Line " 3. $HistoryEndpoint (read-only), Begin = $($Settings.Days):" $pull = Invoke-Pull $session $Settings.Days Write-ApiCallLog $pull $artifact = $pull.Artifact $drawn = 0 $converted = 0 foreach ($r in $artifact['results']) { if ($r['converted']) { $converted++ } else { $drawn++ } } Write-Line (" $($artifact['execs'].Count) execs, $($artifact['results'].Count) results " + "($drawn drawn, $converted auto-converted)") Write-Line " response_sha256 $($pull.Digest)" if ($artifact['execs'].Count -eq 0) { Write-Line ' ! the reply has no pull in this window. Writing the artifact anyway;' Write-Line ' the site will merge it as a no-op.' } try { [void](New-Item -ItemType Directory -Path $Settings.Out -Force -ErrorAction Stop) $path = Join-Path $Settings.Out (Get-ArtifactFilename) # Bytes, not text: on Windows a text write would turn every "\n" into # "\r\n", so the artifact's bytes would depend on the platform. $json = (ConvertTo-MtcoeJson -Value $artifact -Indent 2 -Ascii $false) + [char]10 [System.IO.File]::WriteAllBytes($path, (Get-Utf8 $json)) } catch { throw (New-UserError ("the artifact could not be written to $($Settings.Out): " + "$($_.Exception.Message)" + [char]10 + ' Pass --out (or set MTCOE_OUT) to a directory that can be written.')) } $compact = ConvertTo-MtcoeJson -Value $artifact -Ascii $true $payload = Get-LzUriPayload $compact $url = "$($Settings.SiteUrl)?d=$payload" $size = (Get-Item -LiteralPath $path).Length Write-Line '' Write-Line "artifact -> $path ($size bytes)" Write-Line "payload -> $($compact.Length) bytes of JSON -> $($payload.Length) characters packed" Write-Line '' if (-not $Settings.NoUrl) { Write-Line 'Import it in the browser by opening this link while the LAN server is up:' Write-Line " $url" Write-Line '' } Write-Line 'Or, and this always works (the link above stops working when the server' Write-Line "is down), open the wish-history tab and paste the file's contents:" Write-Line " $path" $State.Code = 0 } # ================================================================== CLI function Read-MtcoeArguments { param([string[]]$RawArgs, [string]$ScriptRoot) $s = [pscustomobject]@{ WhatIf = $false SelfTest = $false TestArtifact = $null UserId = $null DeviceId = $null Identity = $null AppVer = $null Days = $DaysBack Out = $null SiteUrl = $null NoUrl = $false Quiet = $false ShowVersion = $false ShowHelp = $false } # PowerShell's own spellings are bound by the param block; anything it # does not understand (every `--flag`, plus `-q`) arrives in $RawArgs and # is parsed here with the driver's CLI in mind. A value option only says # which field it feeds here - it is assigned once its value is known, so # that `--user-id 123` and `--user-id=123` both work. $args2 = @() if ($RawArgs) { $args2 = $RawArgs } for ($i = 0; $i -lt $args2.Count; $i++) { $token = $args2[$i] $name = $token $inline = $null if ($name.StartsWith('--')) { $name = $name.Substring(2) } elseif ($name.StartsWith('-') -and $name.Length -gt 1) { $name = $name.Substring(1) } if ($name -eq '') { throw (New-ArgumentError "unrecognised argument: $token") } $eq = $name.IndexOf('=') if ($eq -ge 0) { $inline = $name.Substring($eq + 1); $name = $name.Substring(0, $eq) } $name = $name.ToLowerInvariant().Replace('_', '-') $target = '' switch ($name) { 'whatif' { $s.WhatIf = $true } 'selftest' { $s.SelfTest = $true } 'no-url' { $s.NoUrl = $true } 'quiet' { $s.Quiet = $true } 'q' { $s.Quiet = $true } 'version' { $s.ShowVersion = $true } 'help' { $s.ShowHelp = $true } 'h' { $s.ShowHelp = $true } 'user-id' { $target = 'UserId' } 'device-id' { $target = 'DeviceId' } 'identity' { $target = 'Identity' } 'app-ver' { $target = 'AppVer' } 'days' { $target = 'Days' } 'out' { $target = 'Out' } 'site-url' { $target = 'SiteUrl' } 'test-artifact' { $target = 'TestArtifact' } default { throw (New-ArgumentError "unrecognised argument: $token") } } if ($target -eq '') { continue } if ($null -eq $inline) { if (($i + 1) -ge $args2.Count) { throw (New-ArgumentError "argument --$name needs a value") } $i++ $inline = $args2[$i] } if ($target -eq 'Days') { $parsed = 0 if (-not [int]::TryParse($inline, [ref]$parsed)) { throw (New-ArgumentError "argument --days is not a number: $inline") } $s.Days = $parsed } else { $s.$target = $inline } } # The identity file has to be named before anything reads it. if ($s.Identity) { $env:MTCOE_IDENTITY = $s.Identity } if (-not $s.Out) { $s.Out = Get-OutDirDefault } if (-not $s.SiteUrl) { $fromEnv = "$env:MTCOE_SITE_URL".Trim() if ($fromEnv) { $s.SiteUrl = $fromEnv } else { $s.SiteUrl = $DefaultSite } } if ("$env:MTCOE_WHATIF".Trim() -and $env:MTCOE_WHATIF -ne '0') { $s.WhatIf = $true } return $s } function New-ArgumentError([string]$Message) { return New-Object System.ArgumentException $Message } function Write-Usage { Write-Line 'usage: Get-Wishes.ps1 [--whatif] [--user-id ID] [--device-id ID] [--identity FILE]' Write-Line ' [--app-ver VER] [--days N] [--out DIR] [--site-url URL]' Write-Line ' [--no-url] [--quiet] [--version] [--selftest] [--test-artifact FILE]' Write-Line '' Write-Line ' --whatif print the whole plan and touch no network' Write-Line ' --days N 1..90 days back (default 90)' Write-Line ' --selftest offline checks of the ported primitives; nothing is sent' } # ================================================================== selftest function Invoke-MtcoeSelfTest([object]$Settings, [hashtable]$State) { # Offline checks of everything ported by hand. Sends nothing, writes # nothing, prints no key material - only digests and the shipped build # constants' own output. The expected values come from get_wishes.py and # from NIST SP 800-38A; make_link.py prints the artifact numbers. $failures = 0 Write-Line 'SELFTEST (offline: nothing is sent, nothing is written, no key material printed)' Write-Line " puller $PullerName" $os = Get-HostOsVersion $Stencil['APP-OS-VERSION'] $model = Get-HostDeviceModel $Stencil['APP-DEVICE-MODEL'] Write-Line " host os version $os" Write-Line " host device model $model" function Write-Check([string]$Name, [bool]$Ok, [string]$Detail) { $flag = if ($Ok) { 'OK ' } else { 'FAIL' } Write-Line " $($Name.PadRight(17)) $flag $Detail" } # ---- AES known answers, NIST SP 800-38A F.2.1 / F.2.5 $nist128 = '2b7e151628aed2a6abf7158809cf4f3c' $nist256 = '603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4' $nistIv = '000102030405060708090a0b0c0d0e0f' $nistPt = '6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51' + '30c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710' $expect128 = '7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2' + '73bed6b8e3c1743b7116e69e222295163ff1caa1681fac09120eca307586e1a7' $expect256 = 'f58c4c04d6e5f1ba779eabfb5f7bfbd69cfc4e967edb808d679f777bc6702c7d' + '39f23369a9d9bacfa530e26304231461b2eb05e2c39be9fcda6c19078c6a9d1b' $got128 = ConvertTo-HexLower (Copy-Bytes (ConvertTo-AesCbc (ConvertFrom-Hex $nistPt) ` (ConvertFrom-Hex $nist128) (ConvertFrom-Hex $nistIv)) 0 64) $got256 = ConvertTo-HexLower (Copy-Bytes (ConvertTo-AesCbc (ConvertFrom-Hex $nistPt) ` (ConvertFrom-Hex $nist256) (ConvertFrom-Hex $nistIv)) 0 64) $ok128 = ($got128 -eq $expect128) $ok256 = ($got256 -eq $expect256) if (-not $ok128) { $failures++ } if (-not $ok256) { $failures++ } Write-Check 'aes-128-cbc nist' $ok128 'SP 800-38A F.2.1: 4 blocks, 64 bytes' Write-Check 'aes-256-cbc nist' $ok256 'SP 800-38A F.2.5: 4 blocks, 64 bytes' # ---- the IV rule: hex(SHA256(request id))[0:16], as ASCII $rid = 'ABCdef0123456789ABCdef0123456789' $iv = ConvertTo-HexLower (Get-RequestIv $rid) $ok = ($iv -eq '35336634646330393565643238306366') if (-not $ok) { $failures++ } Write-Check 'iv from rid' $ok "$iv (ASCII '$([System.Text.Encoding]::ASCII.GetString((Get-RequestIv $rid)))')" # ---- the body cipher, byte for byte against get_wishes.py $plainRef = Get-Utf8 "mtcoe selftest | non-ascii -> caf$([char]0xE9) $([char]0x2713)" $b64 = [Convert]::ToBase64String((ConvertTo-BodyCipher $plainRef $rid)) $ok = ($b64 -eq 'sBlhI3WHoA3beEmhGqQGMIi9gKVsHFm12n/8gm8BbsMxg+/yl+1gzgl2y0CphvvO') if (-not $ok) { $failures++ } Write-Check 'body cipher' $ok "$(Get-Sha256Hex (Get-Ascii $b64)) (want sBlhI3WH...)" $round = Get-Utf8String (ConvertFrom-BodyCipher (ConvertTo-BodyCipher $plainRef $rid) $rid) $ok = ($round -eq (Get-Utf8String $plainRef)) if (-not $ok) { $failures++ } Write-Check 'body roundtrip' $ok 'encrypt -> decrypt -> the same 39 bytes' # ---- the store cipher (AES-128, explicit MD5 IV) $storeCipher = ConvertTo-AesCbc (Get-Utf8 'mtcoe authdb selftest') $AuthDbAesKey $AuthDbIv $storeHex = ConvertTo-HexLower $storeCipher $ok = ($storeHex -eq 'bc0580056185accbc1e85ce1e80f776bb6d5158670f859281f9af55fc06581a5') if (-not $ok) { $failures++ } Write-Check 'authdb cipher' $ok "$storeHex" $ok = ((Get-Utf8String (ConvertFrom-AesCbc $storeCipher $AuthDbAesKey $AuthDbIv)) -eq 'mtcoe authdb selftest') if (-not $ok) { $failures++ } Write-Check 'authdb decrypt' $ok 'the same cipher python uses on _VALUE' # ---- HMAC-SHA512 as the login body field 6 $hmac = ConvertTo-HexLower (Get-HmacSha512 $LoginHmacKey (Get-Utf8 '0123456789abcdef0123456789abcdef')) $ok = ($hmac -eq ('9e6e90c839fce864fd228c80154f7fddbebd1a74d3f9083a563914c5046774a8' + '8986431c277c70e430473dceb0eb9cc9ee901b32146e5a44a25b57b1d2d3f545')) if (-not $ok) { $failures++ } Write-Check 'hmac-sha512' $ok "$($hmac.Substring(0, 16))..." # ---- the history request: five bytes of protobuf plus the trailer $hex = ConvertTo-HexLower (Get-HistoryRequest 90) $ok = $hex.StartsWith('0a02105a2213') -and $hex.EndsWith('2a00') if (-not $ok) { $failures++ } Write-Check 'history request' $ok "$($hex.Substring(0, 8))... Begin = 90 = 0x5a" # ---- the login device fingerprint, byte for byte $sess = New-ApiSession -PublicUserId '0' -DeviceId 'x' -SignerPem (New-Object byte[] 0) -VerboseLog $false $fp = Get-DeviceFingerprint $sess $ok = ($fp.Length -eq 736) -and ((Get-Sha256Hex (Get-Utf8 $fp)).StartsWith('00a28bb5')) if (-not $ok) { $failures++ } Write-Check 'device fingerprint' $ok "len $($fp.Length) sha256 $((Get-Sha256Hex (Get-Utf8 $fp)).Substring(0, 16))" # ---- lz-string, including the empty / short / non-BMP cases $lzText = '{"a":"' + [char]0xE9 + ' ' + [char]0x65E5 + [char]0x672C + [char]0x8A9E + ' ' + [char]0xD83C + [char]0xDFB2 + '","n":[1,2,3,4000000000],"s":"' + ('x' * 200) + '"}' $lz = Get-LzUriPayload $lzText $ok = ($lz.Length -eq 95) -and ($lz -eq 'N4IghiBcIJcASFPTQNOaDyougeDcE37IA0IB2UA2gIw4BMOAzDgCwAMjTTAungM5QgAevf-AwUOEjRY8RMlTpM2XNEgAvkA') if (-not $ok) { $failures++ } Write-Check 'lz-string' $ok "len $($lz.Length) sha256 $((Get-Sha256Hex (Get-Ascii $lz)).Substring(0, 16))" $edge = @( [pscustomobject]@{ Text = ''; Expect = 'Q' } [pscustomobject]@{ Text = 'abc'; Expect = 'IYIwxkA' } [pscustomobject]@{ Text = (-join ([char]0x65E5, [char]0x672C, [char]0x8A9E, [char]0x306E, [char]0x307F)) Expect = 'qemhpzR5UYdgyP4MQ' } ) $edgeOk = $true foreach ($e in $edge) { if ((Get-LzUriPayload $e.Text) -ne $e.Expect) { $edgeOk = $false } } if (-not $edgeOk) { $failures++ } Write-Check 'lz-string edges' $edgeOk 'empty, 3 ASCII chars, 5 Japanese chars' # ---- the store: read it the way a real run does, print no value $found = Get-AuthDbStore if ($null -ne $found.Store) { $names = (ConvertTo-ObjectArray $found.Store.Rows.Keys | Sort-Object) Write-Line " auth.db store $($found.Store.Path)" Write-Line " $($found.Store.Why)" Write-Line " rows $($names -join ', ') (names only, no values)" $has = $found.Store.Rows.ContainsKey($AuthDbRowKey) -and $found.Store.Rows.ContainsKey($AuthDbRowUserId) -and $found.Store.Rows.ContainsKey($AuthDbRowDeviceId) if (-not $has) { $failures++ } Write-Check 'rows present' $has "prv_key, x_uid, uuid" $pem = $found.Store.Rows[$AuthDbRowKey] $ok = (Test-PemLooksValid $pem) -and ((Get-Sha256Hex $pem).StartsWith('6c45752b')) -and ($pem.Length -eq 522) if (-not $ok) { $failures++ } Write-Check 'pem' $ok "len $($pem.Length) sha256 $((Get-Sha256Hex $pem).Substring(0, 8))" $sig = Get-SignRequest $rid $pem $ok = ((Get-Sha256Hex (Get-Ascii $sig)).StartsWith('6b93b9e6')) if (-not $ok) { $failures++ } Write-Check 'rsa signature' $ok "len $($sig.Length) sha256 $((Get-Sha256Hex (Get-Ascii $sig)).Substring(0, 8))" } else { Write-Line " auth.db store none - $($found.Reason)" Write-Line ' (store checks skipped)' } # ---- the app version and the artifact link, when an artifact is here $appVer = Read-AppVerFromInstall $Settings.AppVer Write-Line " APP-APP-VER $($appVer.Ver) (from $($appVer.Source))" $artifact = $Settings.TestArtifact if (-not $artifact -and $ScriptRoot) { $newest = @( Get-ChildItem -LiteralPath $ScriptRoot -Filter 'wishes-*.json' -File -ErrorAction SilentlyContinue | Sort-Object -Property { $_.Name } ) if ($newest.Count -gt 0) { $artifact = $newest[$newest.Count - 1].FullName } } if ($artifact -and (Test-Path -LiteralPath $artifact -PathType Leaf)) { $doc = ConvertTo-PlainData (ConvertFrom-MtcoeJson (Read-FileText $artifact)) $compact = ConvertTo-MtcoeJson -Value $doc -Ascii $true $payload = Get-LzUriPayload $compact $indented = (ConvertTo-MtcoeJson -Value $doc -Indent 2 -Ascii $false) + [char]10 Write-Line " artifact link $([System.IO.Path]::GetFileName($artifact))" Write-Line " $($compact.Length) bytes of JSON -> $($payload.Length) characters packed" Write-Line " compact sha256 $((Get-Sha256Hex (Get-Utf8 $compact)).Substring(0, 16)) payload sha256 $((Get-Sha256Hex (Get-Ascii $payload)).Substring(0, 16))" Write-Line " indented json sha256 $((Get-Sha256Hex (Get-Utf8 $indented)).Substring(0, 16))" Write-Line ' (compare with: python make_link.py )' } else { Write-Line ' artifact link no wishes-*.json beside the script; pass --test-artifact' } if ($failures -gt 0) { Write-Line " $failures check(s) FAILED" $State.Code = 1 return } Write-Line ' all checks OK' $State.Code = 0 } # ======================================================================== # Host identity (APP-OS-VERSION and APP-DEVICE-MODEL are this machine's) # ======================================================================== function Get-HostOsVersion([string]$Fallback) { # "Windows (..)" for this machine, matching # the client's APP-OS-VERSION shape. Build >= 22000 is Windows 11. try { $ver = [System.Environment]::OSVersion.Version $build = [int]$ver.Build $text = "$($ver.Major).$($ver.Minor).$($ver.Build)" $release = if ($build -ge 22000) { '11' } else { '10' } return "Windows $release ($text)" } catch { return $Fallback } } function Get-HostDeviceModel([string]$Fallback) { # " ()" from the BIOS registry key, # the same board string the client reports as APP-DEVICE-MODEL. try { $key = Get-ItemProperty -LiteralPath 'HKLM:\HARDWARE\DESCRIPTION\System\BIOS' -ErrorAction Stop $product = "$($key.SystemProductName)".Trim() $vendor = "$($key.SystemManufacturer)".Trim() if (-not $product) { return $Fallback } if ($vendor) { return "$product ($vendor)" } return $product } catch { return $Fallback } } # ======================================================================== # Main # ======================================================================== $state = @{ Code = 0 } try { $Stencil['APP-OS-VERSION'] = Get-HostOsVersion ($Stencil['APP-OS-VERSION']) $Stencil['APP-DEVICE-MODEL'] = Get-HostDeviceModel ($Stencil['APP-DEVICE-MODEL']) $settings = Read-MtcoeArguments -RawArgs $RawArgs -ScriptRoot $ScriptRoot if ($settings.ShowHelp) { Write-Usage } elseif ($settings.ShowVersion) { Write-Line $PullerName } elseif ($settings.Days -lt 1 -or $settings.Days -gt $MaxDays) { Write-ErrLine "--days must be 1..$MaxDays (the service rejects more than $MaxDays)" $state.Code = 2 } elseif ($settings.SelfTest) { Invoke-MtcoeSelfTest $settings $state } else { $identity = Resolve-Identity $settings if ($settings.WhatIf) { Write-Plan $settings $identity $state } else { Invoke-PullRun $settings $identity $state } } } catch [System.ArgumentException] { Write-ErrLine '' Write-ErrLine "Get-Wishes.ps1: $($_.Exception.Message)" $state.Code = 2 } catch [System.ApplicationException] { Write-ErrLine '' Write-ErrLine "Get-Wishes.ps1: $($_.Exception.Message)" $state.Code = 3 } catch [System.Management.Automation.PipelineStoppedException] { Write-ErrLine '' Write-ErrLine 'interrupted; nothing was written' $state.Code = 130 } catch { Write-ErrLine '' Write-ErrLine ("Get-Wishes.ps1: unexpected error $($_.Exception.GetType().Name): " + "$($_.Exception.Message)") $state.Code = 1 } $ExitCode.Value = $state.Code } # --------------------------------------------------------------------------- # Where this file is, when it has one. Under `irm ... | iex` it does not: # $PSScriptRoot and $PSCommandPath are empty, and that is also how the script # knows not to call exit (see the header). # --------------------------------------------------------------------------- $MtcoeScriptPath = $PSCommandPath if (-not $MtcoeScriptPath -and $MyInvocation.MyCommand) { $MtcoeScriptPath = $MyInvocation.MyCommand.Path } $MtcoeScriptRoot = '' if ($MtcoeScriptPath) { $MtcoeScriptRoot = Split-Path -Parent $MtcoeScriptPath } $MtcoeExitCode = 0 Invoke-MtcoePuller -ScriptRoot $MtcoeScriptRoot -RawArgs $args -ExitCode ([ref]$MtcoeExitCode) if ($MtcoeScriptPath) { exit $MtcoeExitCode }