diff --git a/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/Callbacks.cs b/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/Callbacks.cs new file mode 100644 index 000000000..6c8943788 --- /dev/null +++ b/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/Callbacks.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using SteamKit2.Internal; + +namespace SteamKit2 +{ + public sealed partial class SteamAuthTicket + { + /// + /// This callback is fired when Steam accepts our auth ticket as valid. + /// + public sealed class TicketAcceptedCallback : CallbackMsg + { + /// + /// of AppIDs of the games that have generated tickets. + /// + public List AppIDs { get; private set; } + /// + /// of CRC32 hashes of activated tickets. + /// + public List ActiveTicketsCRC { get; private set; } + /// + /// Number of message in sequence. + /// + public uint MessageSequence { get; private set; } + + internal TicketAcceptedCallback( JobID jobId, CMsgClientAuthListAck body ) + { + JobID = jobId; + AppIDs = body.app_ids; + ActiveTicketsCRC = body.ticket_crc; + MessageSequence = body.message_sequence; + } + } + + /// + /// This callback is fired when generated ticket was successfully used to authenticate user. + /// + public sealed class TicketAuthCompleteCallback : CallbackMsg + { + /// + /// Steam response to authentication request. + /// + public EAuthSessionResponse AuthSessionResponse { get; } + /// + /// Authentication state. + /// + public uint State { get; } + /// + /// ID of the game the token was generated for. + /// + public GameID GameID { get; } + /// + /// of the game owner. + /// + public SteamID OwnerSteamID { get; } + /// + /// of the game server. + /// + public SteamID SteamID { get; } + /// + /// CRC of the ticket. + /// + public uint TicketCRC { get; } + /// + /// Sequence of the ticket. + /// + public uint TicketSequence { get; } + + internal TicketAuthCompleteCallback( JobID targetJobID, CMsgClientTicketAuthComplete body ) + { + JobID = targetJobID; + AuthSessionResponse = ( EAuthSessionResponse )body.eauth_session_response; + State = body.estate; + GameID = body.game_id; + OwnerSteamID = body.owner_steam_id; + SteamID = body.steam_id; + TicketCRC = body.ticket_crc; + TicketSequence = body.ticket_sequence; + } + } + } +} diff --git a/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/SteamAuthTicket.cs b/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/SteamAuthTicket.cs new file mode 100644 index 000000000..7ef24b213 --- /dev/null +++ b/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/SteamAuthTicket.cs @@ -0,0 +1,228 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Threading.Tasks; +using System.IO.Hashing; +using SteamKit2.Internal; +using System.Diagnostics; +using System.Threading; + +namespace SteamKit2 +{ + /// + /// This handler generates auth session ticket and handles it's verification by steam. + /// + public sealed partial class SteamAuthTicket : ClientMsgHandler + { + private readonly Dictionary> DispatchMap; + private readonly Queue GameConnectTokens = new(); + private readonly Dictionary> TicketsByGame = []; + private readonly object TicketChangeLock = new(); + private static uint Sequence; + + /// + /// Initializes all necessary callbacks. + /// + public SteamAuthTicket() + { + DispatchMap = new Dictionary> + { + { EMsg.ClientAuthListAck, HandleTicketAcknowledged }, + { EMsg.ClientTicketAuthComplete, HandleTicketAuthComplete }, + { EMsg.ClientGameConnectTokens, HandleGameConnectTokens }, + { EMsg.ClientLogOff, HandleLogOffResponse } + }; + } + + /// + /// Performs session ticket generation and validation for specified . + /// + /// Game to generate ticket for. + /// A task representing the asynchronous operation. The task result contains a + /// object that provides details about the generated valid authentication session ticket. + public async Task GetAuthSessionTicket( uint appid ) + { + if ( Client.CellID == null ) throw new Exception( "User not logged in." ); + + var apps = Client.GetHandler() ?? throw new Exception( "Steam Apps instance was null." ); + var appTicket = await apps.GetAppOwnershipTicket( appid ); + + if ( appTicket.Result != EResult.OK ) throw new Exception( $"Failed to obtain app ownership ticket. Result: {appTicket.Result}. The user may not own the game or there was an error." ); + + if ( GameConnectTokens.TryDequeue( out var token ) ) + { + var authTicket = BuildAuthTicket( token ); + var ticket = await VerifyTicket( appid, authTicket, out var crc ); + + // Verify just in case + if ( ticket.ActiveTicketsCRC.Any( x => x == crc ) ) + { + var tok = CombineTickets( authTicket, appTicket.Ticket ); + return new TicketInfo( this, appid, tok ); + } + else + { + throw new Exception( "Ticket verification failed." ); + } + } + else + { + throw new Exception( "There's no available game connect tokens left." ); + } + } + + internal void CancelAuthTicket( TicketInfo authTicket ) + { + lock ( TicketChangeLock ) + { + if ( TicketsByGame.TryGetValue( authTicket.AppID, out var tickets ) ) + { + tickets.RemoveAll( x => x.ticket_crc == authTicket.TicketCRC ); + } + } + + SendTickets(); + } + + private static byte[] CombineTickets( byte[] authTicket, byte[] appTicket ) + { + var len = appTicket.Length; + var token = new byte[ authTicket.Length + 4 + len ]; + var mem = token.AsSpan(); + authTicket.CopyTo( mem ); + MemoryMarshal.Write( mem[ authTicket.Length.. ], in len ); + appTicket.CopyTo( mem[ ( authTicket.Length + 4 ).. ] ); + + return token; + } + + /// + /// Handles generation of auth ticket. + /// + private static byte[] BuildAuthTicket( byte[] gameConnectToken ) + { + const int sessionSize = + 4 + // unknown, always 1 + 4 + // unknown, always 2 + 4 + // public IP v4, optional + 4 + // private IP v4, optional + 4 + // timestamp & uint.MaxValue + 4; // sequence + + using var stream = new MemoryStream( gameConnectToken.Length + 4 + sessionSize ); + using ( var writer = new BinaryWriter( stream ) ) + { + writer.Write( gameConnectToken.Length ); + writer.Write( gameConnectToken ); + + writer.Write( sessionSize ); + writer.Write( 1 ); + writer.Write( 2 ); + + Span randomBytes = stackalloc byte[ 8 ]; + RandomNumberGenerator.Fill( randomBytes ); + writer.Write( randomBytes ); + writer.Write( ( uint )Stopwatch.GetTimestamp() ); + // Use Interlocked to safely increment the sequence number + writer.Write( Interlocked.Increment( ref Sequence ) ); + } + return stream.ToArray(); + } + + private AsyncJob VerifyTicket( uint appid, byte[] authToken, out uint crc ) + { + crc = BitConverter.ToUInt32( Crc32.Hash( authToken ), 0 ); + lock ( TicketChangeLock ) + { + if ( !TicketsByGame.TryGetValue( appid, out var items ) ) + { + items = []; + TicketsByGame[ appid ] = items; + } + + // Add ticket to specified games list + items.Add( new CMsgAuthTicket + { + gameid = appid, + ticket = authToken, + ticket_crc = crc + } ); + } + + return SendTickets(); + } + private AsyncJob SendTickets() + { + var auth = new ClientMsgProtobuf( EMsg.ClientAuthList ); + auth.Body.tokens_left = ( uint )GameConnectTokens.Count; + + lock ( TicketChangeLock ) + { + auth.Body.app_ids.AddRange( TicketsByGame.Keys ); + // Flatten dictionary into ticket list + auth.Body.tickets.AddRange( TicketsByGame.Values.SelectMany( x => x ) ); + } + + auth.SourceJobID = Client.GetNextJobID(); + Client.Send( auth ); + + return new AsyncJob( Client, auth.SourceJobID ); + } + + /// + /// Handles a client message. This should not be called directly. + /// + /// The packet message that contains the data. + public override void HandleMsg( IPacketMsg packetMsg ) + { + ArgumentNullException.ThrowIfNull( packetMsg ); + + if ( DispatchMap.TryGetValue( packetMsg.MsgType, out var handlerFunc ) ) + { + handlerFunc( packetMsg ); + } + } + + #region ClientMsg Handlers + private void HandleLogOffResponse( IPacketMsg packetMsg ) + { + // Clear all game connect tokens on client log off + GameConnectTokens.Clear(); + } + private void HandleGameConnectTokens( IPacketMsg packetMsg ) + { + var body = new ClientMsgProtobuf( packetMsg ).Body; + + // Add tokens + foreach ( var tok in body.tokens ) + { + GameConnectTokens.Enqueue( tok ); + } + + // Keep only required amount, discard old entries + while ( GameConnectTokens.Count > body.max_tokens_to_keep ) + { + GameConnectTokens.TryDequeue( out _ ); + } + } + private void HandleTicketAuthComplete( IPacketMsg packetMsg ) + { + // Ticket successfully used to authorize user + var complete = new ClientMsgProtobuf( packetMsg ); + var inUse = new TicketAuthCompleteCallback( complete.TargetJobID, complete.Body ); + Client.PostCallback( inUse ); + } + private void HandleTicketAcknowledged( IPacketMsg packetMsg ) + { + // Ticket acknowledged as valid by Steam + var authAck = new ClientMsgProtobuf( packetMsg ); + var acknowledged = new TicketAcceptedCallback( authAck.TargetJobID, authAck.Body ); + Client.PostCallback( acknowledged ); + } + #endregion + } +} diff --git a/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/TicketInfo.cs b/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/TicketInfo.cs new file mode 100644 index 000000000..e72e6e2cc --- /dev/null +++ b/SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/TicketInfo.cs @@ -0,0 +1,43 @@ +using System; +using System.IO.Hashing; + +namespace SteamKit2 +{ + public sealed partial class SteamAuthTicket + { + /// + /// Represents a valid authorized session ticket. + /// + public class TicketInfo : IDisposable + { + /// + /// Application the ticket was generated for. + /// + internal uint AppID { get; } + /// + /// Bytes of the valid Session Ticket + /// + public byte[] Ticket { get; } + internal uint TicketCRC { get; } + + internal TicketInfo( SteamAuthTicket handler, uint appID, byte[] ticket ) + { + _handler = handler; + AppID = appID; + Ticket = ticket; + TicketCRC = Crc32.HashToUInt32( ticket ); + } + + /// + /// Discards the ticket. + /// + public void Dispose() + { + _handler.CancelAuthTicket( this ); + System.GC.SuppressFinalize( this ); + } + + private readonly SteamAuthTicket _handler; + } + } +} diff --git a/SteamKit2/SteamKit2/Steam/SteamClient/SteamClient.cs b/SteamKit2/SteamKit2/Steam/SteamClient/SteamClient.cs index 49faf017e..314b4e7a2 100644 --- a/SteamKit2/SteamKit2/Steam/SteamClient/SteamClient.cs +++ b/SteamKit2/SteamKit2/Steam/SteamClient/SteamClient.cs @@ -80,7 +80,7 @@ public SteamClient( SteamConfiguration configuration, string identifier ) HardwareUtils.Init( configuration.MachineInfoProvider ); // add this library's handlers - const int HANDLERS_COUNT = 14; // this number should match the amount of AddHandlerCore calls below + const int HANDLERS_COUNT = 15; // this number should match the amount of AddHandlerCore calls below this.handlers = new( HANDLERS_COUNT ); // notice: SteamFriends should be added before SteamUser due to AccountInfoCallback @@ -98,6 +98,7 @@ public SteamClient( SteamConfiguration configuration, string identifier ) this.AddHandlerCore( new SteamMatchmaking() ); this.AddHandlerCore( new SteamNetworking() ); this.AddHandlerCore( new SteamContent() ); + this.AddHandlerCore( new SteamAuthTicket() ); Debug.Assert( this.handlers.Count == HANDLERS_COUNT );