Draft: Session Token Format
Historically, session tokens were derived directly from user credentials (email, password, optionally 2FA token), but TFS has recently (#4706) transitioned to randomly-generated base64-encoded token stored in the database. However, this change has introduced compatibility issues with existing AACs (such as MyAAC), which rely on the previous credential-based scheme.
Background
The Forgotten Server has evolved significantly in its handling of account authentication. Previously, the session token provided to the client was simply a concatenation of credentials (see below).
This method was simple and easily interoperable with AACs and third-party tools, allowing for stateless verification on the server side.
In newer versions, TFS now issues a randomly-generated token, which is:
- Base64-encoded
- Stored in the database upon generation
- Used as a true bearer token for session continuity
This change was motivated by security concerns with the previous approach, but it has broken compatibility with existing account management websites and tooling.
See #4945 for initial discussion.
Comparison of Token Formats
Plaintext credential tokens (legacy)
Format
email\npassword[\n2FA]
Pros
- Stateless: requires no server-side session persistence
- Easy to implement: AACs and client tools can generate this format with minimal logic
- Portable: Can be easily reused across tools that know the user credentials
Cons
- Vulnerable to replay attacks
- Encourages storing plaintext credentials on clients
- No expiration or revocation mechanism: the token is valid as long as the credentials remain unchanged and 2FA token remains valid (probably lenghtened from 1 minute for session purposes)
Random session tokens (current)
Format
base64(random_bytes(16))
Stored in a sessions table and tied to the account.
Pros
- Can be rotated and invalidated at any time
- Resistant to replay attacks and session hijacking
- Flexible: can store metadata like expiration and IP
Cons
- Requires state: server must store and validate tokens
- Breaks compatibility with current AACs expecting a credentials-based token
- More complex implementation, especially for third-party tools, requiring access to the database
Problem
While the new session token mechanism theoretically improves security, its incompatibility with existing AACs causes usability issues for a large portion of the community. Without official support or clear documentation on the token format and usage, users are forced to modify AACs or downgrade to insecure practices.
There is a need for a well-documented, interoperable session token format that balances security with ease of integration.
Proposed solutions
Option A: Hybrid Token Scheme
Introduce a transitional token format where AACs can optionally request a session token by submitting credentials (via HTTPS) to a /token endpoint, which returns a proper random token.
- AACs don’t generate the token manually
- Server maintains secure session tokens
- Compatibility layer preserves existing AAC workflows
This may require additional steps to make sure the token endpoint is not publicly exposed and only accessible from the AAC.
Option B: Signed Tokens (JWT-like)
Move toward signed tokens (e.g., JWT or custom HMAC-signed tokens) that are:
- Stateless
- Time-limited
- Include account ID and metadata
- Signed using a server-side secret
This maintains security while reducing session storage needs. However, it may be limited by the client's maximum token length.
Option C: compatibility layer
Implement a server-side compatibility layer that detects credential-based tokens, logging a deprecation warning. Support both schemas for a transitioning period (MUST define a deadline) and then fully migrate to a new schema.
Option D: server-handled tokens
Let TFS create and validate the tokens, expose an endpoint in the HTTP server that creates a session token with user and password, therefore AAC does not need to handle game sessions at all and can just relay the token to the client.
Recommendation
To be defined
This kind of touches on a bigger decision which is how compatible should TFS be. Forcing the code base to not only support 20-30 year old code, but also limit itself to current versions of AAC's and other tools which should be updated on their own to be compatible with TFS has created a nightmare in the code base.
I see this question as just "Should we aim for the best practices?" or "Should we do random stuff so its easier for people that don't want to try?"
I vote for whatever is going to make things the most secure. As long as it is possible to create/modify AAC's, tools, ect that is good enough. I would also suggest proper documentation to help people develop/modify AAC's and other tools. But flat out keeping a known bad/less optimal solution in the code base for simplicity will never be something I vote for.
Not that my opinion matters or anything but there it is.
Option B: Signed Tokens (JWT-like)
I'm for this option. Design the best session token algorithm in TFS. Implement it. Add compatibility in MyAAC. Wait for others (canary) to implement it.
What is wrong in old sessions algorithm (old TFS/canary):
- it stores account
passwordin clear text format (not a big deal, it can leak only from client RAM, but if we fix sessions, we should fix this) -> TODO: store it as hash (SHA1 from DB, can be cracked), hash of hash (SHA1 of SHA1 from DB, takes more time, but can be cracked for weak passwords) or SHA1 of SHA1 hash truncated to 30 characters (of 40 characters from SHA1, after 'cracking' it still leaves trillions of possible passwords to try to login [firewall should block 1kkk logins to single account :) ]) - we just need to detect password change, we don't need full password - it stores 2FA (token and token challenge time) in session -> TODO: remove it, 2FA is checked at the time of login (by login server), add 'createAt' or 'expiresAt' field - better 'createdAt', so OTS can check on login, if time of token expired [can be changed in
config.lua, but acc. makers don't have to read 'expires time' config to generate token, just put current date)
I've made 2 propositions. 2nd (steal session) is more serious.
I wrote descriptions in 'first' format and described useful extensions in 'second' format.
Feel free to post you ideas, we are just discussing new sessions ideas to make perfect sessions algorithm.
FIRST: Optimal - not really, go to next example - session token would be 4 lines (or 4 JSON variables) in format:
- account identifier (ID would be optimal [string format, supports MongoDB etc. with UUID as ID, maybe RL Tibia will change it some day, we must be prepared - future proof])
- account password hash truncated to 30 first characters [of 40 SHA1 password characters] (no way to leak/crack password using SHA1 crackers, it will work pretty well, even with Argon2 password schema - future proof)
- 'created at' date as unix timestamp - to make it possible to check 'expires' date on server side, but does not require acc. makers to read some 'expires time' date from OTS config
- SHA1 sign: checksum of 3 lines above (concat using
;separator) withkey.pemSHA1 (SHA1 of RSA key of OTS) added at end of SHA1 sign, it would generate pretty safe 'sign' - to block possibility to modify 'created at' timestamp
SECOND:
To block 'steal session' attack (repeat session attack), we can add clientIp on 4th line and add second SHA1 sign with IP to 6th line:
- account identifier
- account password hash truncated to 30 first characters
- 'created at' date as unix timestamp
- client IP
- SHA1 sign: checksum of first 3 lines (concat using
;separator) withkey.pemSHA1 (SHA1 of RSA key of OTS) added at end of SHA1 sign - SHA1 sign: checksum of first 4 lines (concat using
;separator) withkey.pemSHA1 (SHA1 of RSA key of OTS) added at end of SHA1 sign
In this case, checkClientIp should be configurable on/off in config.lua (default OFF/false).
MyAAC running behind cloudflare.com may get connection from IPv6 address, even if website runs on IPv4, CF will add IPv6 layer and DNSes by default, and pass IPv6 of client in CF-Connecting-IP HTTP header, but TFS will receive IPv4 connection (cannot be protected by cloudflare.com, uses only real dedic/VPS IPv4) with user IPv4 address.
This 'problem' can be fixed by changing CF IPv6 config using CF API ( https://halp.skalski.pro/2024/11/03/how-to-disable-cloudflare-ipv6-access/ ), but we want to keep basic config as simple as possible and add more secure options for more experienced users.
We need 2 signs in 5th and 6th line:
- with
checkClientIp = false, it would check first 3 lines and 5th line sign in TFS - with
checkClientIp = true, it would check first 4 lines and 6th line sign in TFS
Forcing the code base to not only support 20-30 year old code, but also limit itself to current versions of AAC's and other tools which should be updated on their own to be compatible with TFS has created a nightmare in the code base.
I'm pretty sure that MyAAC will add any new sessions format (I can write PHP code for it, making it compatible with old systems [ex. canary]), if it's reasonable.
New format must be somehow detectable for given OTS engine. I think we can use some config.lua variable like checkClientIp = false - checkClientIp being definied in config.lua - to let MyAAC detects what protocol server expects (more info about checkClientIp above).
Should we aim for the best practices?
Yes, but current code is not 'best practices' from 'big OTS (1000+)' point of view and every new OTS owner wants to go 1000+ (at least attempts, so TFS code should not stop it).
When we are talking about 'login' code, most of 12+ OTSes focus on speed and anti-DDoS features.
Most of 12+ OTSes buy separate VPS just for 'login.php' code, to separate it from WWW (acc. maker) and OTS servers.
Server with login.php (or other login script ex. TFS HTTP build-in server) cannot be protected by ex. cloudflare.com default filters, as it would block Tibia Clients as potential attackers and we cannot show 'captcha' in Tibia Client to detect real players.
After some discussion in #4967 , I checked how many bytes are used right now by Tibia 13.10, there are 50-70 free bytes (of 127 RSA-1024 bytes), even, if we allow character name with up to 30 letters.
My new proposed format is 4 lines separated by '\n' or each "line" with fixed length of 10 bytes:
- SHA1 of joined
account ID,;character,first 20 characters of password hash from database,;character andsecret key from config.lua-> converted to hexadecimal format with lowercase letters, truncated to first 10 characters ex.ab12243bd9(10 bytes or 11, if we add\nseparator) - creation date of token as unix timestamp in decimal format ex.
1751922386(it's always 10 bytes for next 250 years) - IP of player - here we must design and test some format that won't be too long, but it must be possible to recreate in all popular programming languages, so OTS can use acc. maker (ex. PHP) or separate login server (ex. Python). Longest possible human formatted (hex with semicolon every 2 bytes) IPv6 has 39 characters ex.
2001:0db8:85a3:0000:0000:8a2e:0370:7334. It's too long. We can use human formatted IPv4/IPv6, but then generate SHA1 of IP and truncate it to 10 bytes in hexadecimal format. - SHA1 of 3 lines above,
;character and secret key, truncated to 10 bytes.
New tokens format benefits:
- nothing to store in database - "login server" can be a separate app running on database slave/snapshot, not on OTS database
- if password to account change, it won't be possible to login anymore
- IP verification is possible, but optional (can be on/off in
config.lua- deafult: off) - tokens may expire after some time (token expiration time in
config.lua- default: 30 days) - there will be no plain text password and even no full SHA1 hash of password (just 20 of 40 bytes), so password to account can't be 'cracked' by some SHA1 cracking tool, even if hacker knows 'secret key from config.lua'
- if hacker somehow gets player session token and 'secret key from config.lua', hacker won't be able to login into website (change password/generate recovery key)
- if 'secret key from config.lua' leaks somehow or server uses 'default secret key' from
config.lua, only things that can be "hacked" are possibility to login from other IP and with outdated token, as hacker will be able to replaceclient IPandcreation date unix timestamplines and generate own token sign
TODO: Make sure that C++ [IP bytes order], PHP and Python generate same IP format for connections from IPv4/IPv6. Otherwise client IP verification will always fail. In PHP it's in human format, not as bytes. If we cannot make it compatible in C++ easily, we should create PHP function to reformat PHP IP into IP format expected by TFS.
Example of token with "\n" separators (43 bytes):
9dd2be4e06
1751964034
e8080f46fa
1e0ba4fba1
Example of token without "\n" separators (40 bytes) - TFS will have to split text by 10 bytes length:
9dd2be4e061751964034e8080f46fa1e0ba4fba1
(it may be better, if JSON makes some problems/conflicts with 'new line' character encoding in C++/PHP/Python)
PHP code - without database connection - to generate token in that format with new line separators:
<?php
// CONFIG
$secretSessionSignKeyFromConfigLua = 'secret-key';
// INPUT DATA FROM CLIENT
$inputDataSendFromClientLoginForm = [
'type' => 'login',
'email' => 'tfs@tfs',
'password' => 'mypassword123',
'token' => '[optional] 2FA token',
];
// IPv4
$clientIp = '1.2.3.4';
// or IPv6
$clientIp = '2001:0db8:85a3:0000:0000:8a2e:0370:7334';
// or short form of IPv6
$clientIp = '::1';
// SESSION TOKEN GENERATION ALGORITHM
function generateSessionToken($email, $clientIp, $secretSessionSignKeyFromConfigLua)
{
// all inputs needed to generate session token are email of an account and client IP
$accountData = getAccountDataFromDatabase($email);
// $accountData['password'] is a hash of password from a database, not plain text password send by Tibia Client\
$first20bytesOfPasswordHash = substr($accountData['password'], 0, 20);
$firstLine = $accountData['id'] . ';' . $first20bytesOfPasswordHash . ';' . $secretSessionSignKeyFromConfigLua;
// sha1 in PHP returns hash in hexadecimal format ex. 356a192b7913b04c54574d18c28d46e6395428ab
$firstLine = sha1($firstLine);
// make sure it's lowercase
$firstLine = strtolower($firstLine);
// truncate to first 10 bytes
$firstLine = substr($firstLine, 0, 10);
// unix timestamp ex. 1751922386
$secondLine = time();
// same as in the first line, but with $clientIp as input data
$thirdLine = sha1($clientIp . ';' . $secretSessionSignKeyFromConfigLua);
$thirdLine = sha1($thirdLine);
$thirdLine = strtolower($thirdLine);
$thirdLine = substr($thirdLine, 0, 10);
// 32 bytes or 30, if we remove `\n` characters
$tokenData = $firstLine . "\n" . $secondLine . "\n" . $thirdLine;
$tokenSign = sha1($tokenData . ';' . $secretSessionSignKeyFromConfigLua);
$tokenSign = strtolower($tokenSign);
$tokenSign = substr($tokenSign, 0, 10);
// 43 bytes or 40, if we remove `\n` characters
$sessionToken = $tokenData . "\n" . $tokenSign;
return $sessionToken;
}
// function that returns account columns from database in key -> value format
function getAccountDataFromDatabase($email)
{
// do database stuff, return data of account with given $email
return [
'id' => 123,
'password' => '356a192b7913b04c54574d18c28d46e6395428ab',
];
}
echo generateSessionToken($inputDataSendFromClientLoginForm['email'], $clientIp, $secretSessionSignKeyFromConfigLua);
I am also strongly for option B, because of the fact that you don't make the ACC reliant on the server to be running to signin.
But I am strongly against what gesior is propsing, especially these parts:
$thirdLine = sha1($clientIp . ';' . $secretSessionSignKeyFromConfigLua); $thirdLine = sha1($thirdLine);
(adding the secret to the jwt signature and then sha1, wtf?)
sha1
separated by '\n' or each "line"
@gesior I am also not entirely sure how you would find the correct account with your token, since you hash and trunace the account id.
Imo it should be something like <algo>.<id>.<iat>.signature.
I would then also:
- Add a
password_updatedfield in the db, to invalidate tokens with aiatolder than that - Dont encode the header/payload at all, it's not needed
- Obviously use the algorithm from the header for the hmac signature
- Probably use something like hmac with blake2s (64/128 bits) for a configureable token length and a secure signature
So something like this:
HSBLAKE2S.123456789.1751927897.12fe57b5c7b200e05e20560a8830faf1
Imo it should be something like
<algo>.<id>.<iat>.signature.
This is good. I'm thinking about not even using the dots, and using the uint64_t account ID and (u)int64_t timestamp, fixing the algorithm to HMAC-SHA256, and base64 on everything.
[8 bytes acc id][8 bytes timestamp][32 bytes signature]
For a total of 64 bytes, leaving plenty of space. It's also as sound as JWT itself.
We could save 4 bytes by assuming account ID will always be 4 bytes (as we currently do), and 4 more by using SHA224 (truncated SHA256) for a total of 56 bytes. IP will not fit this, the minimum amount of space needed for IPv6 address is 16 bytes ballooning to more than the 70 we have.
The solution for invalidating after password change is storing in the database when the password was last changed, and comparing against the timestamp like mentioned above
Your propositions make very simple system more complicated, less secure and with less features :( I will try to write my version today, to present how it works in C++. Maybe it will be easier to understand than that long description.
We want to verify that Tibia Client logging into game:
- knows account ID
- knows current account password
- logged in into account in last X hours/days
- [optional] verify that IP is the same
We also want to:
- hide account ID
- hide account password and make it uncrackable, if hacker gets access to session token and fast GPU
- [optional] hide player IP - not really needed, but if we hide everything, why not IP
Your propositions are not like JWT. JWT is about distributed architecture, where servers generating tokens and servers checking tokens do not communicate with each other, they only need to know same 'secret key':
- insert tokens into database -> login server (AAC) must use game database (OTS)
- make acc. maker save password change date in database -> game server (OTS) must check, what login server (AAC) do
My proposition is also not like JWT, but I take good parts we need (sign data in distributed architecture) and drop things we can't/don't want to use. JWT is about passing data in plain text, but we don't want this part and we can't implement it (limit of bytes). In real JWT scenario, OTS would not require access to table 'accounts' at all. It would read all account data from JWT token and create Account object in C++, but this kind of implementation would not let us detect password change.
But I am strongly against what gesior is propsing, especially these parts: (adding the secret to the jwt signature and then sha1, wtf?)
This is definition of signing document using hashing algorithm. That's what HMAC does. We get benefits of signing it and hashing it at once. No one with access to token can see player IP, but game server can verify, if it's the same on player login to game. We are not only signing whole token with 'sign' part at end (like JWT), we sign every data part of that token (account id, password, IP) to make it unreadable for hacker.
The solution for invalidating after password change is storing in the database when the password was last changed
We can detect it in OTS code with proper token format.
We don't want to track when password was changed, we want to know, if it's different right now. I can change password 1 to 2 and then 2 to 1 and I should be able to relog - password is the same in moment of relog as it was when I logged into account.
Imo it should be something like
<algo>.<id>.<iat>.signature.
We don't want to pass algo name. We have limited number of bytes. If OTS account password is "secure" with SHA1 without salt, token is even more secure with SHA1 (we got salt 'secret key' - same for all accounts, but at least we got some salt).
I am also not entirely sure how you would find the correct account with your token
We can't and we don't have to. We are not verifying access to account, but access to character in game. After session token, Tibia Client sends 'character name', so all we have to do - we must do it anyway - is to load that character account and verify it's data with session token.
using the uint64_t account ID and (u)int64_t timestamp
If you plan to use any binary format ex. 4 bytes describing uint64, don't forget to specify it's bytes order in format documentation and use in C++ function that will force it while converting to/from bytes ( https://en.wikipedia.org/wiki/Endianness ).
IP will not fit this, the minimum amount of space needed for IPv6 address is 16 bytes ballooning to more than the 70 we have.
So we got more bytes (64 vs 40/43) and less features (no password change and IP verification) than in my proposition?
@ranisalt Yeah I though about that too, but base64 of a 8 byte integer is literally longer than just passing the string by itself. Yeah we can probably skip the algo and just invalidate any invalid token in case of algo change. While I would suggess using Blake2/3 I understand that sha224/sha256 is more widely supported out of the box and is probably a better choice because of that.
Regarding the IP, we could still add a 16 or 32 bit crc16/murmur3 hash which would only be 4 to 8 characters long.
@gesior
less secure
You are literally passing the secret into the jwt token, using a truncated sha1 and then talk about us making it less secure?
hide account password and make it uncrackable
There is literally no reason to hide the account id? As I said, how do you even wanna find out to which account the jwt belongs? Also dyou dont have to pass the password at all.
Your propositions are not like JWT. JWT is about distributed architecture, where servers generating tokens and servers checking tokens do not communicate with each other, they only need to know same 'secret key':
They absolutely are.
insert tokens into database -> login server (AAC) must use game database (OTS)
We are not inserting any tokens into the database?
JWT is about passing data in plain text, but we don't want this part
And why so? There is no reason not to do it.
We don't want to track when password was changed, we want to know, if it's different right now. I can change password 1 to 2 and then 2 to 1 and I should be able to relog - password is the same in moment of relog as it was when I logged into account.
Disagree, there is no reason to support such an edge case.
If OTS account password is "secure" with SHA1 without salt, token is even more secure with SHA1 (we got salt 'secret key' - same for all accounts, but at least we got some salt).
The difference is that we are not giving away these sha1 hashes.
EDIT: To make this discussion go anywhere, let's talk about security. Tell me how you - as a hacker - can abuse my proposed token algorithm and I will tell you how I would abuse your code. I'm talking about real examples, not some theory.
Problem 1: Store tokens in database: Attack 1: send multiple valid login (valid e-mail/password) requests, to fill database with millions of records; goal: make SSD/HDD go out of free space Attack 2: send multiple invalid login (random e-mail/password) requests, to slow down database of OTS; goal: every login/logout on OTS will slow down making OTS lagging
How my token algorithm prevent that: Attack 1: tokens are not stored anywhere on server side Attack 2: tokens can be generated using 'slave' of database on separate machine, which won't affect OTS database performance
Problem 2: Plain text in tokens "like JWT" (account ID, IP etc.) Attack 1: if hacker gets somehow player session token, he can use some of that information against player
How my token algorithm prevent that: Attack 1: it does not store any plain text information, all data is stored as HMAC-like signed values, making it impossible/costly (SHA1 cracking server secret key) to read, but easy to verify on server side
We are not inserting any tokens into the database?
Why do you want to insert tokens into the database? New session token format idea is to make it simpler. Old sessions algorithm (like canary) and JWT do not do this, and both work perfectly fine. We can design our session token to work without database and has the same security as current token with database.
There is literally no reason to hide the account id? (...) And why so? There is no reason not to do it.
There is no reason to make it visible. Problem started in 2024, when TFS changed old sessions system (that one with plain password in session), because authors though that sessions can be stolen from client / network communication. If we assume that it's possible, we should hide all data that is not needed and can be abused somehow by hackers.
As I said, how do you even wanna find out to which account the jwt belongs?
You can't and you don't have to.
I answered to it in comment above in answer to ranisalt's question I am also not entirely sure how you would find the correct account with your token.
Also dyou dont have to pass the password at all.
How do you want to detect that password is different? Again, I already posted it in comment above, but we want to detect that password is different, not that someone 'changed it' (track some AAC action), because user can change it back to old password.
You are literally passing the secret into the jwt token using a truncated sha1
Again. You are literally describing that I'm doing what HMAC does and you say that it's not secure.
The difference is that we are not giving away these sha1 hashes.
We also do not give players session tokens to hackers, if this is our security concept, just revert changes to TFS session algorithm and use old version with plain text password in token.
I am not even sure what to answer to this, you are literally quoting my words and commenting on the exact opposite I said.
Also you can add billions of sha1 hashes to your token but that doesn't make it more 'secure'. If the token get's stolen somehow you can signin using the token unless we add the ip anyways. Encoding/hashing everything just adds uneccessary overhead. The token is tranfered using rsa/xtea anyways which secures against MITM attacks.
You are talking about performance when you are doing 4x sha1 instead of my suggestion of one hmac with blake2s/3s (which is already way better in terms of performance) or sha256, it's actually insane.
And once again, I am not suggesting to store the jwt token anywhere, if you would read carefully you could've known that from my first comment.
Also I am against including the ip, because that could backfire real fast because of various reasons (vpns, changing location, isp rotations, ipv6overipv4, etc.)
@ranisalt
Your suggestion looks good, but I would switch to blake2 instead of sha256.
First example implementation of my new token algorithm: https://github.com/otland/forgottenserver/commit/cfa0cb10b8860db51fa59416dd3b3e7476bc6402
- Secret session key ("secretkey"), IP validation (on) and token expiration time (30 seconds) are now hardcoded in C++. They will be moved to
config.lua. - Most of session code is in
game.cpp(SessionTokenclass). IDK, if it's fine for your. Maybe it should be moved to own filessessiontoken.cppandsessiontoken.h. - C++ functions that generate tokens in
game.cppare line by line reproductions of PHP code (to make it easier to analyse during development), they can be changed to single lines later ex. now it looks like that:
std::string SessionToken::signAccountAndPassword(uint32_t accountId, std::string_view passwordHash)
{
auto first20bytesOfPasswordHash = passwordHash.substr(0, 20);
std::string firstLine = std::format("{:d};{:s};{:s}", accountId, first20bytesOfPasswordHash, signKey);
std::string firstLineSha1 = transformToSHA1hexadecimal(firstLine);
std::string firstLineSha1Lowercase = boost::algorithm::to_lower_copy(firstLineSha1);
std::string firstLineSha1Truncated = firstLineSha1Lowercase.substr(0, 10);
return firstLineSha1Truncated;
}
- Hash function is
transformToSHA1hexadecimal, which is SHA1 with hex format output. It can be easily replaced with SHA256 or other well known hash algorithm. We must decide which to use.
Also I am against including the ip, because that could backfire real fast because of various reasons (vpns, changing location, isp rotations, ipv6overipv4, etc.)
I think it should be optional and off by default, but it should be available. We are making 'new better session token' version, so it should not lack any features of old/current TFS sessions algorithm:
- IP verification
- detection of password change
You are talking about performance when you are doing 4x sha1 instead of my suggestion of one hmac with blake2s/3s (which is already way better in terms of performance) or sha256, it's actually insane.
I know that blake3 is 3 times faster than sha1, but it's not yet added to ex. PHP:
https://wiki.php.net/rfc/blake3
and we design new session token, to make it compatible with popular acc. makers ex. MyAAC - as it's now impossible to use TFS with any acc. maker.
Also you can add billions of sha1 hashes to your token but that doesn't make it more 'secure'
My token does not use multiple sha1 calls to make it more secure token. It uses multiple sha1 calls to secure different information, but keep them possible to compare with database/client data on server side (ex. IP verification on/off). We can switch it to SHA256, if you think it's more secure, but I would not switch it to some new not widely implemented standard.
I made draft of my session tokens:
https://github.com/otland/forgottenserver/pull/4970
Including PHP code for MyAAC login.php that works with it: https://gist.github.com/gesior/6fb7e194a1aedb7980e98399bcca628d
It describes new token format in just one function, easy to read for anyone who knows PHP: https://gist.github.com/gesior/6fb7e194a1aedb7980e98399bcca628d#file-login-php-L45-L58
There is no need to store the password. If you're signing, you can trust the account ID alone. Also, don't truncate the hash, you're slashing security by doing to.
With that said, I'm getting inclined to just let the server handle logins and sessions, and AACs should query the server for a token. It's just too messy otherwise.
Also, don't truncate the hash, you're slashing security by doing to.
It does not reduce security below acceptable level. Tokens are still much harder to crack, than most of account passwords. Even, if server does not set custom session key (uses "" - default empty string), so you can crack token, you won't get account e-mail/password.
It's token full of signed data (hashed data; with missing bytes [just half of password hash!]). It does not contain ANY information about account or IP of player.
Adding more bytes of hash does not make token more secure. Between 0 bytes and full hash length bytes (64 for SHA-256) is barrier between leaking information about account/IP and making it harder to crack. Maybe it's 20, maybe 30, but for me 10 bytes is enough of information and I leave 54 bytes for 'harder to crack'.
As I said before To make this discussion go anywhere, let's talk about security.
Let's attack it as a hacker!
Assume that owner of server is not an idiot. He sets 10+ random characters as 'secret token key' in config.lua.
We can even enforce it in TFS, by shutting OTS down on startup, if session key is not set to X+ characters. I don't think it makes sense, TFS should be easy to run on localhost with default config.lua. Even with "" session key (default empty key), hacker won't be able to break account ID/password or 'somehow' login into game on someone else account.
// extra info: 16^10 is 1.099.511.627.776 SHA-256 hashes to generate -> 1.1 kkkk, a lot of GPU/CPU power
You can login into game using your account/password and known IP - your IP - and try to crack servers config.lua secret key sessionTokenSecretKey by cracking 'singed IP' returned by server in session.
You may think, it will cost you just 16^10 of SHA-256 calculations, as I trimmed sign to 10 bytes, but what do you get after 16^10 calculations? You get some 'secret key' that cracked your IP. Will it crack all IPs and sing any session token? No, you are still missing 54 bytes of SHA-256 hash (16^54). Your 'cracked secret key' works just for your IP/account ID/password, not for all tokens.
What to do? Hmm.. get more IPv4/IPv6 IPs, send 1kk logins to TFS/MyAAC in 24 hours, you get 10^6 different IPs and their SHA-256 signs. Put it into cracking algorithm.. you reduced unknown bytes from 16^54 to ~16^49 (10^6 is around 16^5), 49 bytes to go. How many more IPs can you get? For sure not 16^54.
Other way to crack 'sessionTokenSecretKey' is to crack token by 'timestamp' of login, but it gives you 1 more signed token per second per account (as it's timestamp and it's part of token final 'sign'). If you use 1 account-password pair, it will give you just 86.400 new tokens to crack per day per account. You can try maybe 10 or 100 accounts per second, but higher amount of logins per second will DoS OTS/acc. maker, so you cannot get much more.
Now go back to small OTS owner, who has no idea that there is 'sessionTokenSecretKey'. He left it empty ("") in config, so you already know 'secret session key' of OTS.
Somehow (from RAM of Tibia Client? network?) you get some player token and try to crack login/password. There are 2 problems:
- to login into acc. maker (to change password), you need account name/e-mail (depending on OTS client version/acc. maker), not account ID!
- ignore previous point, assume, that you somehow get account name/e-mail by ID using some bug on website 'account recovery' page
- you still get only 20 bytes of 40 bytes of SHA1 of password, it means that there are
16^20unknown bytes, which you will have to fill and try to login into site/OTS, good luck calling any server 16^20 times
- you still get only 20 bytes of 40 bytes of SHA1 of password, it means that there are
I hope that little view into cryptography and calculation complexity explained why 10 bytes per sign is enough for token.
If you have any attack that can break any part of my session token system, POST IT! I would like to know, if I'm wrong in any part of it.
There is no need to store the password.
How can you detect, that password to account changed, if you do not store it in token?
It's very common scenario on OTSes:
- player uses same login/password on multiple OTSes
- hacker - other OTS owner - get database from his 'failed OTS' and try to login to all accounts on other OTS
- hacker changes password to account and login to steal items
- player gets kick in client and realize he is getting hacked
- player go acc. maker and change password using recovery key
- player login into game, to kick hacker as fast as possible
without password stored in token, hacker could login again until token expires, even after password change. Most of OTSes don't want to force relog on players, as they may decide to close client, every time you ask them to go some notepad and copy login/password for OTS, so they will probably use XX days expiration time.
Storing account id/password signed in token is just 1 call to SHA-256 and 10 bytes of data. If password changed, there is 1 in 16^10 chance (10 hex characters), it will not detect it. So it's 99.99999...% chance, that it will detect password change with that simple 10 bytes of SHA-256 hash.
Only other way to do detect password change, would be to force all acc. makers and other scripts that may change account password (IDK, phpmyadmin?), to save date of password modification in some column and on account load in TFS, check if password was modified after 'token creation date'. It would be still worse than my token, as it would detect password as 'changed', even if user changed password by mistake from 1 to 2, realized it and changed back from 2 to 1 in acc. maker.
With that said, I'm getting inclined to just let the server handle logins and sessions, and AACs should query the server for a token.
No real OTS will allow OTS to handle logins thru HTTP, even with acc. maker as proxy. It's too easy to DDoS and it hits OTS directly with HTTP server running as part of TFS.
Everyone use acc. makers to login. Big servers use login.php hosted on separate subdomain like login.otsdomain.com and it points to separate VPS/dedic, that only role is to process login request. Some of them write own Python/C++ apps for 'login' with in-RAM cache for maximum efficiency.
That's why I said it's important for new token format to do not store anything in OTS database. VPS with login.php may has only 'read' access to OTS database, as on this separate VPS runs 'MySQL slave' of OTS database (real time, read-only, copy of OTS database).
It's just too messy otherwise.
IDK what is messy. We got 8 lines of PHP logic to generate that token: https://gist.github.com/gesior/6fb7e194a1aedb7980e98399bcca628d#file-login-php-L47-L57 If we ignore all PSR recommendations, it can be 2 lines of logic in PHP:
function generateSecureSessionToken($id, $passwordHash, $clientIp, $secretSessionSignKey): string
{
// line 1
$tokenData = substr(hash('sha256', $id . ';' . substr($passwordHash, 0, 20) . ';' . $secretSessionSignKey), 0, 10) . time() . substr(hash('sha256', $clientIp . ';' . $secretSessionSignKey), 0, 10);
// line 2
return $tokenData . substr(hash('sha256', $tokenData . ';' . $secretSessionSignKey), 0, 10);
}
EDIT:
From TFS C++ point of view, we moved all token mess into SessionToken class, it works 100% with input arguments, no inner state.
@gesior Hashing the header/payload of a jwt doesn't make sense. If I know how the token is made up (and even it not) it literally does not matter because I can regenerate all hashes if I know the secret. So you really only need the signature hash, anything else is just pseudo security by obscurity.
@ranisalt I am pro decoupling the login system from the game server. It's literally just
$payload = base64_encode(pack("J", accountId) . pack("J", timestamp));
$token = $payload . hash_hmac('sha256', $payload , $secretKey);
Hashing the header/payload of a jwt doesn't make sense.
We are not implementing JWT. This format does not work with Tibia protocol (RSA 127 bytes limit) and does not offer features we need (detect password change without compromising password hash - it uses plain text payload).
Let's go back to pure cryptography and tell me that hashing secret data (payload = password and IP) does not work. If not, why does everyone hash passwords in database?
$payload = base64_encode(pack("J", accountId) . pack("J", timestamp)); $token = $payload . hash_hmac('sha256', $payload , $secretKey);
Where are password and IP?
Token PHP code you posted with just accountId and timestamp already generates 88 bytes for account 123. We got limit of 127 bytes and we must put there character name (up to 25 bytes), XTEA key (16 bytes) and few other information required by Tibia protocol.
You still propose format that offers less features and takes more bytes. Why?
I can regenerate all hashes if I know the secret.
Do you understand how hashing works? Idea is that there are so many hashes (16^64 for SHA-256), that no computer on earth can generate them all.
So you really only need the signature hash, anything else is just pseudo security by obscurity.
Password is not obscured. Password SHA1 hash is cut in half (20 of 40 bytes) and there is no way to get second half. It's joined with account ID and hashed with secretKey to make it impossible to compare password hash first 20 bytes with databases of well known passwords hashes.
IP is obscured and IF YOU KNOW secret key (I expect you to do not know it, as I set it to random value in config.lua on MY server), you can generate hashes for all IPv4 addresses (4kkk) and 'crack' what is my IP from my token. It won't work for IPv6 (34kkkk.. [37x k] of addresses), because there are too many to hash them all.
We are not implementing JWT. This format does not work with Tibia protocol (RSA 127 bytes limit) and does not offer features we need (detect password change without compromising password hash - it uses plain text payload).
Dude, call it however you want. It is a token for authentification and we are following the same shemantics.
Let's go back to pure cryptography and tell me that hashing secret data (payload = password and IP) does not work. If not, why does everyone hash passwords in database?
Because, again, we don't have to. It doesn't make any difference and just adds unneccessary cpu load for absolutely no reason.
$payload = base64_encode(pack("J", accountId) . pack("J", timestamp)); $token = $payload . hash_hmac('sha256', $payload , $secretKey);
Where are password and IP? Token PHP code you posted with just accountId and timestamp already generates 88 bytes for account
123. We got limit of 127 bytes and we must put there character name (up to 25 bytes), XTEA key (16 bytes) and few other information required by Tibia protocol. You still propose format that offers less features and takes more bytes. Why?
Once again, we do not need that. I've explained already how we can handle password changes and I'm aganst including the ip. Whatever the account (123, or 213123) the token doesn't gets any longer because I'm converting/packing all eight bytes of the unsigned integer.
I can regenerate all hashes if I know the secret.
Do you understand how hashing works? Idea is that there are so many hashes (16^64 for SHA-256), that no computer on earth can generate them all.
Man I don't even know where to start explaining what I'm talking about. I am not talking about brute forcing the hash, but about regenerating it if I know the secret. And if I do your complete sha1/trunace php stuff becomes absolutely useless. There is no difference in security between our payloads even if you hash yours jesus god damn christ. The only thing that matters it the signature which you TRUNCATE and which REDUCES security because it becomes easier to create a collision. And that is why the payload should become as short as possible to increase the signature bits. Please look that up before continue arguing against my and ranisalt's proposal.
IP is obscured and IF YOU KNOW secret key (I expect you to do not know it, as I set it to random value in
config.luaon MY server), you can generate hashes for all IPv4 addresses (4kkk) and 'crack' what is my IP from my token. It won't work for IPv6 (34kkkk.. [37xk] of addresses), because there are too many to hash them all.
It does not matter. Because I would use a sha256 hmac anyways which is way more secure than whatever you are cooking there.
Let's go back to pros/cons discussion, as this is token format thread, not my PR.
Compare 2 proposals with current TFS session token
Current TFS sessions algorithm Pros:
- tokens have expiration date
- IP validation [always, not optional]
- no plain text password in token (comparing to old algorithm)
Cons:
- stores data in database
- no password change detection in token
JWT like algorithm with account ID and long sign https://github.com/otland/forgottenserver/pull/4967 Pros:
- acc. maker can generate it without writing anything to database
- tokens have expiration date
- strong security with super long sign [we must still test, if 88 bytes fits into RSA, if character name is long]
Cons:
- no password change detection in token - [still only proposed, not added to PR] someone else (acc. maker authors) would have to modify their software, to store last password modification date, so we could compare it with token creation date
- no IP validation
Custom sign algorithm with few shorter signs in place of one long sign https://github.com/otland/forgottenserver/pull/4970 Pros:
- acc. maker can generate it without writing anything to database
- tokens have expiration date
- password change detection in token
- IP validation [optional, configurable]
- custom sign algorithm, designed for OTS needs - balances between features and security [not really]
Cons:
- custom sign algorithm [truncated signs], not like any popular standard
The only thing that matters it the signature which you TRUNCATE and which REDUCES security because it becomes easier to create a collision
It does not matter that much in our scenario. Only way to test, if your random sign collide with real sign is to login into OTS. You cannot 'crack' it on your PC easier, because it's shorter. With 10 bytes, you would need 16^10 / 2 login attempts. It's 549.755.813.888 attempts, it's 1000 logins per second for 17 years to login into single account. If we increase number of bytes from 10 to 15, it will increase attack time by over 1kk times.
If it's not secure enough for you, we can increase it to 20, 30 or 50 bytes, as long as it fits into RSA. Just tell me a number.
You are getting into lines of my PR draft like it's final version.
If you can argue, that some part of my PR security is too weak and it can be cracked using some method, we can increase number of bytes of any part of it, as long as it fits into RSA packet.
There is absolutely no problem to replace my custom sign sha256($data . $secretKey) with HMAC hmac('sha256', $data, $secretKey) (still truncated to limited number of bytes), if you prefer it as more standard sign.
Token PHP code you posted with just accountId and timestamp already generates 88 bytes for account
123
It's actually 32 bytes for the hash + 8 bytes for account ID + 8 bytes for timestamp, totalling 48 bytes. However, the token must be URL-encoded because the login is JSON-based, so it's 64 bytes of base64-encoded data or maybe less if we use a more compact encoding (60 bytes for base85)
I don't like the long explanations and scenarios where the owner is dumb but the server still needs to be secure. I prefer if we adopt a nothing-up-my-sleeve even if server owners need to read up a little bit - it's not even that hard. If one wants to defeat any security by using an empty secret, let them be.