Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GetAuthSessionTicket implementation #1407

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/Callbacks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using SteamKit2.Internal;

namespace SteamKit2
{
public sealed partial class SteamAuthTicket
{
/// <summary>
/// This callback is fired when Steam accepts our auth ticket as valid.
/// </summary>
public sealed class TicketAcceptedCallback : CallbackMsg
{
/// <summary>
/// <see cref="List{T}"/> of AppIDs of the games that have generated tickets.
/// </summary>
public List<uint> AppIDs { get; private set; }
/// <summary>
/// <see cref="List{T}"/> of CRC32 hashes of activated tickets.
/// </summary>
public List<uint> ActiveTicketsCRC { get; private set; }
/// <summary>
/// Number of message in sequence.
/// </summary>
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;
}
}

/// <summary>
/// This callback is fired when generated ticket was successfully used to authenticate user.
/// </summary>
public sealed class TicketAuthCompleteCallback : CallbackMsg
{
/// <summary>
/// Steam response to authentication request.
/// </summary>
public EAuthSessionResponse AuthSessionResponse { get; }
/// <summary>
/// Authentication state.
/// </summary>
public uint State { get; }
/// <summary>
/// ID of the game the token was generated for.
/// </summary>
public GameID GameID { get; }
/// <summary>
/// <see cref="SteamKit2.SteamID"/> of the game owner.
/// </summary>
public SteamID OwnerSteamID { get; }
/// <summary>
/// <see cref="SteamKit2.SteamID"/> of the game server.
/// </summary>
public SteamID SteamID { get; }
/// <summary>
/// CRC of the ticket.
/// </summary>
public uint TicketCRC { get; }
/// <summary>
/// Sequence of the ticket.
/// </summary>
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;
}
}
}
}
229 changes: 229 additions & 0 deletions SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/SteamAuthTicket.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
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
{
/// <summary>
/// This handler generates auth session ticket and handles it's verification by steam.
/// </summary>
public sealed partial class SteamAuthTicket : ClientMsgHandler
{
private readonly Dictionary<EMsg, Action<IPacketMsg>> DispatchMap;
private readonly Queue<byte[]> GameConnectTokens = new();
private readonly Dictionary<uint, List<CMsgAuthTicket>> TicketsByGame = [];
private readonly object TicketChangeLock = new();
private static uint Sequence;

/// <summary>
/// Initializes all necessary callbacks.
/// </summary>
public SteamAuthTicket()
{
DispatchMap = new Dictionary<EMsg, Action<IPacketMsg>>
{
{ EMsg.ClientAuthListAck, HandleTicketAcknowledged },
{ EMsg.ClientTicketAuthComplete, HandleTicketAuthComplete },
{ EMsg.ClientGameConnectTokens, HandleGameConnectTokens },
{ EMsg.ClientLogOff, HandleLogOffResponse }
};
}

/// <summary>
/// Performs <see href="https://partner.steamgames.com/doc/api/ISteamUser#GetAuthSessionTicket">session ticket</see> generation and validation for specified <paramref name="appid"/>.
/// </summary>
/// <param name="appid">Game to generate ticket for.</param>
/// <returns>A task representing the asynchronous operation. The task result contains a <see cref="TicketInfo"/>
/// object that provides details about the generated valid authentication session ticket.</returns>
public async Task<TicketInfo> GetAuthSessionTicket( uint appid )
{
if ( Client.CellID == null ) throw new Exception( "User not logged in." );

var apps = Client.GetHandler<SteamApps>() ?? 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;
}

/// <summary>
/// Handles generation of auth ticket.
/// </summary>
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<byte> 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<TicketAcceptedCallback> 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<TicketAcceptedCallback> SendTickets()
{
var auth = new ClientMsgProtobuf<CMsgClientAuthList>( 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<TicketAcceptedCallback>( Client, auth.SourceJobID );
}

/// <summary>
/// Handles a client message. This should not be called directly.
/// </summary>
/// <param name="packetMsg">The packet message that contains the data.</param>
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<CMsgClientGameConnectTokens>( 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<CMsgClientTicketAuthComplete>( 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<CMsgClientAuthListAck>( packetMsg );
var acknowledged = new TicketAcceptedCallback( authAck.TargetJobID, authAck.Body );
Client.PostCallback( acknowledged );
}
#endregion
}
}
40 changes: 40 additions & 0 deletions SteamKit2/SteamKit2/Steam/Handlers/SteamAuthTicket/TicketInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using System.IO.Hashing;

namespace SteamKit2
{
/// <summary>
/// Represents a valid authorized session ticket.
/// </summary>
public sealed partial class TicketInfo : IDisposable
xPaw marked this conversation as resolved.
Show resolved Hide resolved
{
/// <summary>
/// Application the ticket was generated for.
/// </summary>
internal uint AppID { get; }
/// <summary>
/// Bytes of the valid Session Ticket
/// </summary>
public byte[] Ticket { get; }
internal uint TicketCRC { get; }

internal TicketInfo( SteamAuthTicket handler, uint appID, byte[] ticket )
{
_handler = handler;
AppID = appID;
Ticket = ticket;
TicketCRC = BitConverter.ToUInt32( Crc32.Hash( ticket ), 0 );
xPaw marked this conversation as resolved.
Show resolved Hide resolved
}

/// <summary>
/// Discards the ticket.
/// </summary>
public void Dispose()
{
_handler.CancelAuthTicket( this );
System.GC.SuppressFinalize( this );
}

private readonly SteamAuthTicket _handler;
}
}
3 changes: 2 additions & 1 deletion SteamKit2/SteamKit2/Steam/SteamClient/SteamClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 );

Expand Down