├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | test.js 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alexander Corn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Steam TOTP 2 | [![npm version](https://img.shields.io/npm/v/steam-totp.svg)](https://npmjs.com/package/steam-totp) 3 | [![npm downloads](https://img.shields.io/npm/dm/steam-totp.svg)](https://npmjs.com/package/steam-totp) 4 | [![license](https://img.shields.io/npm/l/steam-totp.svg)](https://github.com/DoctorMcKay/node-steam-totp/blob/master/LICENSE) 5 | [![paypal](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=N36YVAT42CZ4G&item_name=node%2dsteam%2dtotp¤cy_code=USD) 6 | 7 | This lightweight module generates Steam-style 5-digit alphanumeric two-factor authentication codes given a shared secret. 8 | 9 | **As of v2.0.0, Node.js v6.0.0 or later is REQUIRED. This LTS Node.js release will be supported by this module for the duration of Node's LTS support.** 10 | 11 | Usage is simple: 12 | 13 | ```js 14 | var SteamTotp = require('steam-totp'); 15 | var code = SteamTotp.generateAuthCode('cnOgv/KdpLoP6Nbh0GMkXkPXALQ='); 16 | ``` 17 | 18 | [Read more about Steam's 2FA and trade confirmations.](https://dev.doctormckay.com/topic/289-trading-and-escrow-mobile-trade-confirmations/) 19 | 20 | ## time([timeOffset]) 21 | - `timeOffset` - Default 0 if omitted. This many seconds will be added to the returned value. 22 | 23 | **v1.2.0 or later is required to use this function** 24 | 25 | Simply returns the current local time in Unix time. This is just `Math.floor(Date.now() / 1000) + timeOffset`. 26 | 27 | ## getAuthCode(secret[, timeOffset][, callback]) 28 | - `secret` - Your `shared_secret`, as a `Buffer`, hex string, or base64 string 29 | - `timeOffset` - Optional. If you know your clock's offset from the Steam servers, you can provide it here. This number of seconds will be added to the current time to produce the final time. Default 0. 30 | - `callback` - Optional. If you provide a callback, then the auth code will **not** be returned and it will be provided to the callback. If provided, the module will also account for time discrepancies with the Steam servers. If you use this, **do not** provide a `timeOffset`. 31 | - `err` - An `Error` object on failure, or `null` on success 32 | - `code` - Your auth code, as a string 33 | - `offset` - Your time offset, in seconds. You can pass this to `time` later if you need to, for example to get confirmation keys. 34 | - `latency` - The time in milliseconds between when we sent our request and when we received a response from the Steam time server. 35 | 36 | **v1.4.0 or later is required to use `callback`.** 37 | 38 | Returns your current 5-character alphanumeric TOTP code as a string (if no callback is provided) or queries the current 39 | time from the Steam servers and returns the code in the callback (if the callback was provided). 40 | 41 | **Note:** You should use your `shared_secret` for this function. 42 | 43 | *Alias: generateAuthCode(secret[, timeOffset][, callback])* 44 | 45 | ## getConfirmationKey(identitySecret, time, tag) 46 | - `identitySecret` - Your `identity_secret`, as a `Buffer`, hex string, or base64 string 47 | - `time` - The Unix time for which you are generating this secret. Generally should be the current time. 48 | - `tag` - The tag which identifies what this request (and therefore key) will be for. "conf" to load the confirmations page, "details" to load details about a trade, "allow" to confirm a trade, "cancel" to cancel it. 49 | 50 | **v1.1.0 or later is required to use this function** 51 | 52 | Returns a string containing your base64 confirmation key for use with the mobile confirmations web page. 53 | 54 | **Note:** You should use your `identity_secret` for this function. 55 | 56 | *Alias: generateConfirmationKey(identitySecret, time, tag)* 57 | 58 | ## getTimeOffset(callback) 59 | - `callback` - Called when the request completes 60 | - `error` - An `Error` object, or `null` on success 61 | - `offset` - The time offset in seconds 62 | - `latency` - The time in milliseconds between when we sent the request and when we received a response 63 | 64 | **v1.2.0 or later is required to use this function** 65 | 66 | Queries the Steam servers for their time, then subtracts our local time from it to get our offset. 67 | 68 | The offset is how many seconds we are **behind** Steam. Therefore, **add** this number to our local time to get Steam time. 69 | 70 | You can pass this value to `time()` or to `getAuthCode()` as-is with no math involved. 71 | 72 | ## getDeviceID(steamID) 73 | - `steamID` - Your SteamID as a string or an object (such as a `SteamID` object) which has a `toString()` method that returns the SteamID as a 64-bit integer string. 74 | 75 | **v1.3.0 or later is required to use this function** 76 | 77 | Returns a standardized device ID in the same format as Android device IDs from Valve's official mobile app. Steam will 78 | likely soon stop allowing you to send a different device ID each time you load the page, instead requiring you to 79 | consistently use the same device ID. If you use this function's algorithm everywhere you use a confirmation device ID, 80 | then your experience should be fine. 81 | 82 | The algorithm used is: 83 | 84 | 1. Convert the SteamID to a string 85 | 2. Append the value of the `STEAM_TOTP_SALT` environment variable to the SteamID, if it's set 86 | 3. SHA-1 hash it and encode the resulting hash as a lowercase hex value 87 | 4. Truncate the hash to 32 characters 88 | 5. Insert dashes such that the resulting value has 5 groups of hexadecimal values containing 8, 4, 4, 4, and 12 characters, respectively 89 | 6. Prepend "android:" to the resulting value 90 | 91 | Note: `STEAM_TOTP_SALT` was added to the v1 branch in v1.5.0 and to the v2 branch in v2.1.0. 92 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Crypto = require('crypto'); 4 | 5 | /** 6 | * Returns the current local Unix time 7 | * @param {number} [timeOffset=0] - This many seconds will be added to the returned time 8 | * @returns {number} 9 | */ 10 | exports.time = function(timeOffset) { 11 | return Math.floor(Date.now() / 1000) + (timeOffset || 0); 12 | }; 13 | 14 | /** 15 | * Generate a Steam-style TOTP authentication code. 16 | * @param {Buffer|string} secret - Your TOTP shared_secret as a Buffer, hex, or base64 17 | * @param {number} [timeOffset=0] - If you know how far off your clock is from the Steam servers, put the offset here in seconds 18 | * @returns {string} 19 | */ 20 | exports.generateAuthCode = exports.getAuthCode = function(secret, timeOffset) { 21 | if (typeof timeOffset === 'function') { 22 | exports.getTimeOffset((err, offset, latency) => { 23 | if (err) { 24 | timeOffset(err); 25 | return; 26 | } 27 | 28 | let code = exports.generateAuthCode(secret, offset); 29 | timeOffset(null, code, offset, latency); 30 | }); 31 | 32 | return; 33 | } 34 | 35 | secret = bufferizeSecret(secret); 36 | 37 | let time = exports.time(timeOffset); 38 | 39 | let buffer = Buffer.allocUnsafe(8); 40 | // The first 4 bytes are the high 4 bytes of a 64-bit integer. To make things easier on ourselves, let's just pretend 41 | // that it's a 32-bit int and write 0 for the high bytes. Since we're dividing by 30, this won't cause a problem 42 | // until the year 6053. 43 | buffer.writeUInt32BE(0, 0); 44 | buffer.writeUInt32BE(Math.floor(time / 30), 4); 45 | 46 | let hmac = Crypto.createHmac('sha1', secret); 47 | hmac = hmac.update(buffer).digest(); 48 | 49 | let start = hmac[19] & 0x0F; 50 | hmac = hmac.slice(start, start + 4); 51 | 52 | let fullcode = hmac.readUInt32BE(0) & 0x7FFFFFFF; 53 | 54 | const chars = '23456789BCDFGHJKMNPQRTVWXY'; 55 | 56 | let code = ''; 57 | for (let i = 0; i < 5; i++) { 58 | code += chars.charAt(fullcode % chars.length); 59 | fullcode /= chars.length; 60 | } 61 | 62 | return code; 63 | }; 64 | 65 | /** 66 | * Generate a base64 confirmation key for use with mobile trade confirmations. The key can only be used once. 67 | * @param {Buffer|string} identitySecret - The identity_secret that you received when enabling two-factor authentication 68 | * @param {number} time - The Unix time for which you are generating this secret. Generally should be the current time. 69 | * @param {string} tag - The tag which identifies what this request (and therefore key) will be for. "conf" to load the confirmations page, "details" to load details about a trade, "allow" to confirm a trade, "cancel" to cancel it. 70 | * @returns {string} 71 | */ 72 | exports.generateConfirmationKey = exports.getConfirmationKey = function(identitySecret, time, tag) { 73 | identitySecret = bufferizeSecret(identitySecret); 74 | 75 | let dataLen = 8; 76 | 77 | if (tag) { 78 | if (tag.length > 32) { 79 | dataLen += 32; 80 | } else { 81 | dataLen += tag.length; 82 | } 83 | } 84 | 85 | let buffer = Buffer.allocUnsafe(dataLen); 86 | 87 | // Auto-detect whether we have support for Buffer#writeUInt64BE and use it if we can. If we have writeUInt64BE 88 | // then we also definitely have BigInt. 89 | if (buffer.writeBigUInt64BE) { 90 | buffer.writeBigUInt64BE(BigInt(time), 0); 91 | } else { 92 | // Fall back to old Y2K38-unsafe behavior. 93 | // If you're still using Node.js <10.20.0 in 2038, you only have yourself to blame. 94 | buffer.writeUInt32BE(0, 0); 95 | buffer.writeUInt32BE(time, 4); 96 | } 97 | 98 | if (tag) { 99 | buffer.write(tag, 8); 100 | } 101 | 102 | let hmac = Crypto.createHmac('sha1', identitySecret); 103 | return hmac.update(buffer).digest('base64'); 104 | }; 105 | 106 | exports.getTimeOffset = function(callback) { 107 | let start = Date.now(); 108 | let req = require('https').request({ 109 | "hostname": "api.steampowered.com", 110 | "path": "/ITwoFactorService/QueryTime/v1/", 111 | "method": "POST", 112 | "headers": { 113 | "Content-Length": 0 114 | } 115 | }, (res) => { 116 | if (res.statusCode != 200) { 117 | callback(new Error("HTTP error " + res.statusCode)); 118 | return; 119 | } 120 | 121 | let response = ''; 122 | res.on('data', (chunk) => { 123 | response += chunk; 124 | }); 125 | 126 | res.on('end', () => { 127 | try { 128 | response = JSON.parse(response).response; 129 | } catch(e) { 130 | callback(new Error("Malformed response")); 131 | } 132 | 133 | if (!response || !response.server_time) { 134 | callback(new Error("Malformed response")); 135 | } 136 | 137 | let end = Date.now(); 138 | let offset = response.server_time - exports.time(); 139 | 140 | callback(null, offset, end - start); 141 | }); 142 | }); 143 | 144 | req.on('error', callback); 145 | 146 | req.end(); 147 | }; 148 | 149 | /** 150 | * Get a standardized device ID based on your SteamID. 151 | * @param {string|object} steamID - Your SteamID, either as a string or as an object which has a toString() method that returns the SteamID 152 | * @returns {string} 153 | */ 154 | exports.getDeviceID = function(steamID) { 155 | let salt = process.env.STEAM_TOTP_SALT || ''; 156 | return "android:" + Crypto.createHash('sha1').update(steamID.toString() + salt).digest('hex') 157 | .replace(/^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12}).*$/, '$1-$2-$3-$4-$5'); 158 | }; 159 | 160 | function bufferizeSecret(secret) { 161 | if (typeof secret === 'string') { 162 | // Check if it's hex 163 | if (secret.match(/[0-9a-f]{40}/i)) { 164 | return Buffer.from(secret, 'hex'); 165 | } else { 166 | // Looks like it's base64 167 | return Buffer.from(secret, 'base64'); 168 | } 169 | } 170 | 171 | return secret; 172 | } 173 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "steam-totp", 3 | "version": "2.1.2", 4 | "description": "Generate Steam-style TOTP codes given a secret", 5 | "keywords": [ 6 | "steam", 7 | "steam client", 8 | "steam guard" 9 | ], 10 | "homepage": "https://github.com/DoctorMcKay/node-steam-totp", 11 | "bugs": { 12 | "url": "https://github.com/DoctorMcKay/node-steam-totp/issues" 13 | }, 14 | "license": "MIT", 15 | "author": { 16 | "name": "Alexander Corn", 17 | "email": "mckay@doctormckay.com", 18 | "url": "https://www.doctormckay.com" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/DoctorMcKay/node-steam-totp.git" 23 | }, 24 | "engines": { 25 | "node": ">=6.0.0" 26 | } 27 | } 28 | --------------------------------------------------------------------------------