UNPKG

steamauth

Version:

SteamGuard code generator and trade confirmations

1,039 lines (921 loc) 24.7 kB
/*jslint node: true */ "use strict"; /* * Copyright (C) 2015 Colin Mackie <winauth@gmail.com>. * * This software is distributed under the terms of the GNU General Public License. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ // builtin var util = require('util'); var EventEmitter = require('events').EventEmitter; var crypto = require("crypto"); // external var _ = require('underscore'); var base32 = require('rfc-3548-b32'); var request = require("request"); var async = require("async"); var NodeRSA = require('node-rsa'); var cheerio = require("cheerio"); var bunyan = require("bunyan"); /** * Create a new SteamAuth object to create authenticator codes or communicate with Steam * passing in either a secret key (base32) string or a config object of auth data. * * If passing in the secret data from the Steam mobile app (/data/data/com.valvesoftware.android.steam.community/files/SteamGuard-XXXXX), * * e.g. * var auth = new SteamAuth(); * var auth = new SteamAuth("JHYTFVDHSKDHJASGD"); * var auth = new SteamAuth({secret:"JHYTFVDHSKDHJASGD"}); * var auth = new SteamAuth({ * "deviceid":"android:0147986e-a56e-4c59-82df-5b04d813a3a8", * "shared_secret":"4H+kWXz5p5XHk2/5M0C2XQM=", * "identity_secret":"4i14n2+yOn5vq+495mQ+Yirw=" * }); * * @param options string secret or mobile app authenticator data * @param complete callback(err) * @constructor */ var SteamAuth = function SteamAuth(options, complete) { var self = this; EventEmitter.call(this); if (typeof options === "function" && !complete) { complete = options; options = {}; } if (!options) { options = {}; } if (typeof options === "string") { options = {secret:options}; } if (options.loglevel) { SteamAuth.Logger.level(options.loglevel); } self.session = {}; self.auth = {}; if (options.shared_secret) { _.extend(self.auth, options); } else if (options.secret) { _.extend(self.auth, options); self.auth.shared_secret = decodeSecretToBuffer(options.secret, options.encoding).toString("base64"); } // synchronise time if (!options.time && (typeof options.sync === "undefined" || options.sync)) { // force resync if (options.sync === true) { SteamAuth.Offset = 0; } SteamAuth.Sync(function(err, offset) { if (err) { self.emit("error", err); if (complete) { complete(err); } return; } if (complete) { complete(null, offset); } self.emit("ready"); }); } else { if (complete) { complete(); } self.emit("ready"); } }; util.inherits(SteamAuth, EventEmitter); /** * Create default logger */ SteamAuth.Logger = bunyan.createLogger({name:"SteamAuth"}); SteamAuth.Logger.level("warn"); /** * Interval period i.e. 30 seconds * @type {number} */ SteamAuth.INTERVAL_PERIOD_MS = 30000; /** * Buffer size of int64 * @type {number} */ SteamAuth.INT64_BUFFER_SIZE = 8; /** * Maximum Int32 value * @type {number} */ SteamAuth.MAX_INT32 = Math.pow(2,32); /** * Number of digits in SteamGuard code * @type {number} */ SteamAuth.DIGITS = 5; /** * SteamGuard code character alphabet * @type {string[]} */ SteamAuth.ALPHABET = [ '2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'T', 'V', 'W', 'X', 'Y']; /** * URL to Steam server sync function * @type {string} */ SteamAuth.SYNC_URL = "https://api.steampowered.com/ITwoFactorService/QueryTime/v0001"; SteamAuth.COMMUNITY_BASE = "https://steamcommunity.com"; SteamAuth.WEBAPI_BASE = "https://api.steampowered.com"; SteamAuth.API_GETWGTOKEN = SteamAuth.WEBAPI_BASE + "/IMobileAuthService/GetWGToken/v0001"; SteamAuth.Offset = 0; /** * Class method to perform a time sync request to Steam and set offset. * * @param complete callback with error and offset */ SteamAuth.Sync = function(complete) { SteamAuth.Syncing = true; if (SteamAuth.Offset) { setTimeout(function() { complete(null, SteamAuth.Offset); }, 10); return; } request({ url:SteamAuth.SYNC_URL, method:"POST", headers:{ accept: "*/*", }, json:true }, function(err, response, body) { if (response.statusCode != 200) { return complete({message:"Non 200 response from Steam"}); } if (!body || !body.response || !body.response.server_time) { return complete({message:"Invalid time response from Steam"}); } var servertime = parseInt(body.response.server_time) * 1000; var offset = SteamAuth.Offset = new Date().getTime() - servertime; complete(null, offset); } ); }; /** * Internal function to perform correct request to Steam * * @param opts url, method, data, cookies and header * @param complete callback with err and body */ SteamAuth.request = function(opts, complete) { if (!opts.headers) { opts.headers = {}; } _.extend(opts.headers, { accept: "text/javascript, text/html, application/xml, text/xml, */*", "User-Agent": "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", "Referrer": SteamAuth.COMMUNITY_BASE, "X-Requested-With": "com.valvesoftware.android.steam.community" }); if (!opts.cookies) { opts.cookies = request.jar(); } var reqopts = { url:opts.url, method:opts.method || "GET", headers:opts.headers, jar:opts.cookies, form:(opts.method == "POST" ? opts.data : null), qs:(opts.method == "GET" ? opts.data : null), json:!!opts.json }; SteamAuth.Logger.debug("Request", reqopts); request( reqopts, function(err, response, body) { if (err) { SteamAuth.Logger.error("Response Error", response, body); } else { SteamAuth.Logger.debug("Response", reqopts, response, body); } return complete(err, response, body); } ); }; /** * Login to a new session or update an existing session (e.g. captcha). * * If not done already, you should add the deviceid value from * /data/data/com.valvesoftware.android.steam.community/shared_prefs/steam.uuid.xml * * var client = new SteamAuth().login({username:"username", password:"password", deviceid:"android-123123"}, complete); * * If callback returns error, it can be because of invalid password, invalid 2fa code or needing Captcha. * * @param opts object containing username and password * @param complete callback returning (err, session) */ SteamAuth.prototype.login = function(opts, complete) { var self = this; if (!opts) { var err = {message: "opts parameter is required"}; self.emit("error", err); if (complete) { complete(err); } return; } var session = self.session || {}; session.error = null; var username = opts.username || session.username; var password = opts.password || session.password; if (opts.deviceid) { self.auth.deviceid = opts.deviceid; } var cookies = session.cookies = (session.cookies || request.jar()); cookies.setCookie(request.cookie("mobileClientVersion=3067969+%282.1.3%29"), SteamAuth.COMMUNITY_BASE + "/"); cookies.setCookie(request.cookie("mobileClient=android"), SteamAuth.COMMUNITY_BASE + "/"); cookies.setCookie(request.cookie("steamid="), SteamAuth.COMMUNITY_BASE + "/"); cookies.setCookie(request.cookie("steamLogin="), SteamAuth.COMMUNITY_BASE + "/"); cookies.setCookie(request.cookie("Steam_Language=english"), SteamAuth.COMMUNITY_BASE + "/"); cookies.setCookie(request.cookie("dob="), SteamAuth.COMMUNITY_BASE + "/"); async.series([ // get latest server time function(complete) { SteamAuth.Sync(complete); }, // setup initial cookies function(complete) { if (session.oauth) { return complete(); } SteamAuth.request({ url:SteamAuth.COMMUNITY_BASE + "/login?oauth_client_id=DE45CD61&oauth_scope=read_profile%20write_profile%20read_client%20write_client", cookies:cookies }, function(err, response) { return complete(err); }); }, // get RSA key function(complete) { if (session.oauth) { return complete(); } SteamAuth.request({ url:SteamAuth.COMMUNITY_BASE + "/login/getrsakey", method:"POST", data:{username:username}, cookies:cookies, json:true }, function(err, response, body) { if (err) { return complete(err); } if (!body.success) { return complete({message:"Unknown user " + username}); } session.timestamp = body.timestamp; session.publicpem = createRsaPublicPem(body.publickey_mod, body.publickey_exp); complete(); }); }, // perform login function(complete) { if (session.oauth) { return complete(); } var key = new NodeRSA(session.publicpem, "public", {encryptionScheme:"pkcs1"}); var epassword64 = key.encrypt(password, "base64", "utf8"); var twofactorcode = opts.twofactorcode || ""; if (!twofactorcode && self.auth && self.auth.shared_secret) { twofactorcode = self.calculateCode(); } var response = SteamAuth.request({ url: SteamAuth.COMMUNITY_BASE + "/login/dologin/", method: "POST", data: { username: username, password: epassword64, twofactorcode: twofactorcode, emailauth: opts.emailauthtext || "", loginfriendlyname: "#login_emailauth_friendlyname_mobile", captchagid: opts.captchaid || "-1", captcha_text: opts.captchatext || "enter above characters", emailsteamid: (opts.emailauthtext ? session.steamid || "" : ""), rsatimestamp: session.timestamp, remember_login: "false", oauth_client_id: "DE45CD61", oauth_scope: "read_profile write_profile read_client write_client", donotache: new Date().getTime() }, cookies: cookies, json:true }, function(err, response, body) { if (err) { return complete(err); } if (!body) { return complete({message: "Invalid login response"}); } if (body.emailsteamid) { session.steamid = body.emailsteamid; } if (!body.login_complete || !body.oauth) { if (body.captcha_needed) { // caller must provide {captchaid, captchatext} var captchaid = body.captcha_gid; var url = SteamAuth.COMMUNITY_BASE + "/public/captcha.php?gid=" + captchaid; err = new Error("Need captcha for " + url); err.captchaid = captchaid; err.captchaurl = url; return complete(err); } if (body.emailauth_needed) { // caller must provide {emailauthtext} return complete(new Error("Need email auth code " + body.emaildomain || "")); } if (body.requires_twofactor) { // caller must provide {twofactorcode} return complete(new Error("Need 2FA")); } return complete(new Error(body.message || "Invalid login")); } try { session.oauth = JSON.parse(body.oauth); } catch (ex) { complete(ex); } if (!session.oauth || !session.oauth.oauth_token) { return complete(new Error("Expected OAUTH token")); } if (session.oauth.steamid) { session.steamid = session.oauth.steamid; } complete(); } ); } ], function(err) { if (err) { return complete(err); } complete(null, session); } ); }; /** * Refresh the login session to update cookies. Must be logged in. * * Will call a callback with error or success flag. e.g. refresh(function(err,success) { ... }); * If success is false, normal login is required. * * @param complete function(err, success) */ SteamAuth.prototype.refresh = function(complete) { var self = this; if (!self.session || !self.session.oauth) { return complete(null, false); } SteamAuth.request({ url: SteamAuth.API_GETWGTOKEN, method: "POST", data: { access_token: self.session.oauth.oauth_token }, cookies: self.session.cookies, json:true }, function(err, response, body) { if (err) { return complete(err); } if (!body || !body.response) { return complete(null, false); } self.session.cookies.setCookie(request.cookie("steamLogin=" + self.session.steamid + "%7C%7C" + body.response.token), SteamAuth.COMMUNITY_BASE + "/"); self.session.cookies.setCookie(request.cookie("steamLoginSecure=" + self.session.steamid + "%7C%7C" + body.response.token_secure), SteamAuth.COMMUNITY_BASE + "/"); complete(null, true); } ); }; /** * Get an array of current trade confirmation objects: * { * id:confirmation id, * key:confirmation key, * image:<url of trader>, * online:(bool) if trader is online * details:description, * traded:item received, * when:time info * } * * @param complete callback with error or trades array (err, trades) */ SteamAuth.prototype.getTradeConfirmations = function(complete) { var self = this; if (!self.session || !self.session.oauth) { return complete({message:"not logged in"}); } var tag = "conf"; var servertime = Math.floor((new Date().getTime() + (SteamAuth.Offset || 0)) / 1000); var timehash = createTimeHash(servertime, tag, self.auth.identity_secret); SteamAuth.request({ url: SteamAuth.COMMUNITY_BASE + "/mobileconf/conf", method: "GET", data: { p: self.auth.deviceid, a: self.session.steamid, k: timehash, t: servertime, m: "android", tag: tag }, cookies: self.session.cookies, json:true }, function(err, response, body) { if (err) { return complete(err); } var trades = []; var $ = cheerio.load(body); $(".mobileconf_list_entry").each(function() { var $entry = $(this); var id = $entry.attr("data-confid"); if (id) { var trade = {id:id}; trade.key = $entry.attr("data-key"); trade.image = $(".mobileconf_list_entry_icon img", $entry).attr("src"); var $details = $(".mobileconf_list_entry_description > div", $entry); trade.details = $details.length > 0 ? $details.eq(0).html() : ""; trade.traded = $details.length > 1 ? $details.eq(1).html() : ""; trade.when = $details.length > 2 ? $details.eq(2).html() : ""; trades.push(trade); } }); SteamAuth.Logger.info("GetTradeConfirmations", trades); complete(null, trades); } ); }; /** * Reject a trade confirmation by its id and key from getTradeConfirmations * * @param id id of trade * @param key key for trade * @param complete (err) return err if error or failed */ SteamAuth.prototype.rejectTradeConfirmation = function(id, key, complete) { this.sendConfirmation(id, key, "cancel", complete); }; /** * Accept a trade confirmation by its id and key from getTradeConfirmations * * @param id id of trade * @param key key for trade * @param complete (err) return err if error or failed */ SteamAuth.prototype.acceptTradeConfirmation = function(id, key, complete) { this.sendConfirmation(id, key, "allow", complete); }; /** * Send a trade confirmation response * * @param id id of trade * @param key key for trade * @param tag tag of response, e.g "cancel", "allow" * @param complete (err) if error or failed * @returns {*} */ SteamAuth.prototype.sendConfirmation = function(id, key, tag, complete) { var self = this; if (!self.session || !self.session.oauth) { return complete({message:"not logged in"}); } var servertime = Math.floor((new Date().getTime() + (SteamAuth.Offset || 0)) / 1000); var timehash = createTimeHash(servertime, tag, self.auth.identity_secret); SteamAuth.request({ url: SteamAuth.COMMUNITY_BASE + "/mobileconf/ajaxop", method: "GET", data: { op: tag, p: self.auth.deviceid, a: self.session.steamid, k: timehash, t: servertime, m: "android", tag: tag, cid: id, ck: key }, cookies: self.session.cookies, json:true }, function(err, response, body) { if (err) { return complete(err); } if (!body.success) { return complete(new Error("Unable to " + tag + " " + id)); } complete(); } ); }; /** * Get full details about a trade confirmation * * NOT YET IMPLEMENTED * * @param id of of trade * @param complete (err,trade) err if error occured, else trade object * @returns {*} */ SteamAuth.prototype.getTradeConfirmation = function(id, complete) { var self = this; if (!self.session || !self.session.oauth) { return complete({message:"not logged in"}); } var tag = "details" + id; var servertime = Math.floor((new Date().getTime() + (SteamAuth.Offset || 0)) / 1000); var timehash = createTimeHash(servertime, tag, self.auth.identity_secret); SteamAuth.request({ url: SteamAuth.COMMUNITY_BASE + "/mobileconf/details/" + id, method: "GET", data: { p: self.auth.deviceid, a: self.session.steamid, k: timehash, t: servertime, m: "android", tag: tag }, cookies: self.session.cookies, json:true }, function(err, response, body) { if (err) { return complete(err); } if (!body.success) { return complete(new Error("Unknown Conf " + id)); } var $ = cheerio.load(body.html); var $trade = $(".mobileconf_trade_area"); if (!$trade.length) { return complete(new Error("Cannot find trade")); } var trade = {partner:{}, items:[], receiving:[]}; var $partner = $(".trade_partner_header", $trade); trade.partner.name = $(".trade_partner_headline_sub a", $partner).html(); trade.partner.steamid = /[\s\S]*\/profiles\/([\s\S]*)/i.exec($(".trade_partner_headline_sub a", $partner).attr("href")); //trade.partner.icon = $(".trade_partner_icon img", $partner).attr("src"); //$(".tradeoffer_item.primary") complete(null, trade, body.html); } ); }; /** * Convienience class method for quick call to calculate authenticator code * * @param options secret key or options object (see SteamAuth::calculateCode) * @param time optional time in ms * @returns {string} authenticator code */ SteamAuth.calculateCode = function(options, time) { if (typeof options === "string") { options = {secret:options}; } if (time) { options.time = time; } var auth = new SteamAuth({sync:!options.time}); return auth.calculateCode(options); }; /** * Calculate the SteamGuard code from the current or supplied time given Base32 secret key. * If the time is supplied, it must include any drift between the host and Steam servers. * * @param options Either Base32 (RFC3548) encoded secret key or options object {secret:encoded secret key, * time:time to use in ms, encoding:base32|base64|hex encoding of secret} * @returns {string} 5 character SteamGuard code */ SteamAuth.prototype.calculateCode = function(options, time) { var self = this; if (!options) { options = {}; } else if (typeof options === "string") { options = {shared_secret:decodeSecretToBuffer(options, "base32")}; } if (time) { options.time = time; } var secretBuffer; if (options.secret) { secretBuffer = decodeSecretToBuffer(options.secret, options.encoding || "base32"); } else if (options.shared_secret) { secretBuffer = decodeSecretToBuffer(options.shared_secret, options.encoding || "base64"); } else if (self.auth) { secretBuffer = decodeSecretToBuffer(self.auth.shared_secret, "base64"); } else { var err = {message:"No secret key defined"}; self.emit("error", err); throw err; } // use the current or supplier time time = options.time; if (!time) { time = new Date().getTime() + SteamAuth.Offset; } // calculate interval var interval = Math.floor(time / SteamAuth.INTERVAL_PERIOD_MS); var buffer = new Buffer(SteamAuth.INT64_BUFFER_SIZE); buffer.writeUInt32BE(Math.floor(interval / SteamAuth.MAX_INT32), 0); buffer.writeUInt32BE(interval % SteamAuth.MAX_INT32, 4); // create hash var hmac = crypto.createHmac("sha1", secretBuffer); var mac = hmac.update(buffer).digest(); // extract code value from hash var start = mac[19] & 0x0f; var value = mac.readUInt32BE(start) & 0x7fffffff; // convert code value into char values var code = ""; for (var i=0; i<SteamAuth.DIGITS; i++) { code += SteamAuth.ALPHABET[value % SteamAuth.ALPHABET.length]; value = Math.floor(value / SteamAuth.ALPHABET.length); } return code; }; /** * Decode a secret string from the given or guessed encoding into a Buffer * * @param secret encoded secret string * @returns {*} Buffer containing secret */ function decodeSecretToBuffer(secret, encoding) { if (!secret || secret instanceof Buffer) { return secret; } if (!encoding) { if (/^([ABCDEF0-9]{2})+$/i.test(secret)) { // test for hex encoding = "hex"; } else if (/^[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]+$/.test(secret)) { // test for base32 encoding = "base32"; } else { // else probably base64 encoding = "base64"; } } // test for hex if (encoding == "hex") { return new Buffer(secret, "hex"); } else if (encoding == "base32") { return base32.decode(secret); } else if (encoding == "base64") { return new Buffer(secret, "base64"); } else { throw {message:"Unknown encoding " + encoding}; } } /** * Convert a public key modulus and exponent into a pkcs8 pem * * @param modulus * @param exponent * @returns {string} */ function createRsaPublicPem(modulus, exponent) { function prepadSigned(hexStr) { var msb = hexStr[0]; if ((msb>='8' && msb<='9') || (msb>='a' && msb<='f') || (msb>='A'&&msb<='F')) { return "00" + hexStr; } else { return hexStr; } } function toHex(number) { var nstr = number.toString(16); if (nstr.length % 2 === 0) { return nstr; } else { return "0" + nstr; } } // encode ASN.1 DER length field // if <=127, short form // if >=128, long form function encodeLengthHex(n) { if (n <= 127) { return toHex(n); } else { var n_hex = toHex(n); var length_of_length_byte = 128 + n_hex.length/2; // 0x80+numbytes return toHex(length_of_length_byte)+n_hex; } } modulus = prepadSigned(modulus); exponent = prepadSigned(exponent); var modlen = modulus.length/2; var explen = exponent.length/2; var encoded_modlen = encodeLengthHex(modlen); var encoded_explen = encodeLengthHex(explen); var encoded_pubkey = "30" + encodeLengthHex( modlen + explen + encoded_modlen.length/2 + encoded_explen.length/2 + 2 ) + "02" + encoded_modlen + modulus + "02" + encoded_explen + exponent; var seq2 = "30 0d " + "06 09 2a 86 48 86 f7 0d 01 01 01" + "05 00 " + "03" + encodeLengthHex(encoded_pubkey.length/2 + 1) + "00" + encoded_pubkey; seq2 = seq2.replace(/ /g, ""); var der_hex = "30" + encodeLengthHex(seq2.length/2) + seq2; der_hex = der_hex.replace(/ /g, ""); var der = new Buffer(der_hex, "hex"); var der_b64 = der.toString("base64"); return "-----BEGIN PUBLIC KEY-----\n" + der_b64.match(/.{1,64}/g).join("\n") + "\n-----END PUBLIC KEY-----\n"; } /** * Create the time hash for Steam mobile calls * * @param time current time * @param tag operation tag * @param secret identity_secret * @returns base64 hash */ function createTimeHash(time, tag, secret) { var b64secret = new Buffer(secret, "base64"); var bufferSize = 8; if (tag) { bufferSize += Math.min(32, tag.length); } var buffer = new Buffer(bufferSize); buffer.writeUInt32BE(Math.floor(time / SteamAuth.MAX_INT32), 0); buffer.writeUInt32BE(time % SteamAuth.MAX_INT32, 4); if (tag) { buffer.write(tag, 8, bufferSize - 8, "utf8"); } var hmac = crypto.createHmac("sha1", b64secret); var mac = hmac.update(buffer).digest(); return mac.toString("base64"); } module.exports = SteamAuth;