# Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com). # Licensed under the terms of the MIT license. See LICENCE for details. require "spec_helper" describe LastPass::Fetcher do let(:username) { "username" } let(:password) { "password" } let(:key_iteration_count) { 5000 } let(:hash) { "7880a04588cfab954aa1a2da98fd9c0d2c6eba4c53e36a94510e6dbf30759256" } let(:session_id) { "53ru,Hb713QnEVM5zWZ16jMvxS0" } let(:escaped_session_id) { "53ru%2CHb713QnEVM5zWZ16jMvxS0" } let(:session) { LastPass::Session.new session_id, key_iteration_count, "DEADBEEF" } let(:blob_response) { "TFBBVgAAAAMxMjJQUkVNAAAACjE0MTQ5" } let(:blob_bytes) { blob_response.decode64 } let(:blob) { LastPass::Blob.new blob_bytes, key_iteration_count, "DEADBEEF" } let(:login_post_data) { {method: "cli", xml: 2, username: username, hash: hash, iterations: key_iteration_count, includeprivatekeyenc: 1} } let(:device_id) { "492378378052455" } let(:login_post_data_with_device_id) { login_post_data.merge({imei: device_id}) } let(:google_authenticator_code) { "123456" } let(:yubikey_password) { "emdbwzemyisymdnevznyqhqnklaqheaxszzvtnxjrmkb" } let(:login_post_data_with_google_authenticator_code) { login_post_data.merge({otp: google_authenticator_code}) } let(:login_post_data_with_yubikey_password) { login_post_data.merge({otp: yubikey_password}) } describe ".logout" do it "makes a GET request" do web_client = double "web_client" expect(web_client).to receive(:get) .with("https://lastpass.com/logout.php?method=cli&noredirect=1", cookies: {"PHPSESSID" => escaped_session_id}) .and_return(http_ok "") LastPass::Fetcher.logout session, web_client end it "raises an exception on HTTP error" do expect { LastPass::Fetcher.logout session, double("web_client", get: http_error) }.to raise_error LastPass::NetworkError end end describe ".request_iteration_count" do it "makes a POST request" do expect(web_client = double("web_client")).to receive(:post) .with("https://lastpass.com/iterations.php", body: {email: username}) .and_return(http_ok(key_iteration_count.to_s)) LastPass::Fetcher.request_iteration_count username, web_client end it "returns key iteration count" do expect( LastPass::Fetcher.request_iteration_count username, double("web_client", post: http_ok(key_iteration_count.to_s)) ).to eq key_iteration_count end it "raises an exception on HTTP error" do expect { LastPass::Fetcher.request_iteration_count username, double("web_client", post: http_error) }.to raise_error LastPass::NetworkError end it "raises an exception on invalid key iteration count" do expect { LastPass::Fetcher.request_iteration_count username, double("web_client", post: http_ok("not a number")) }.to raise_error LastPass::InvalidResponseError, "Key iteration count is invalid" end it "raises an exception on zero key iteration count" do expect { LastPass::Fetcher.request_iteration_count username, double("web_client", post: http_ok("0")) }.to raise_error LastPass::InvalidResponseError, "Key iteration count is not positive" end it "raises an exception on negative key iteration count" do expect { LastPass::Fetcher.request_iteration_count username, double("web_client", post: http_ok("-1")) }.to raise_error LastPass::InvalidResponseError, "Key iteration count is not positive" end end describe ".request_login" do def verify_post_request multifactor_password, device_id, post_data web_client = double("web_client") expect(web_client).to receive(:post) .with("https://lastpass.com/login.php", format: :xml, body: post_data) .and_return(http_ok("response" => {"ok" => {"sessionid" => session_id, "privatekeyenc" => "DEADBEEF"}})) LastPass::Fetcher.request_login username, password, key_iteration_count, multifactor_password, device_id, web_client end it "makes a POST request" do verify_post_request nil, nil, login_post_data end it "makes a POST request with device id" do verify_post_request nil, device_id, login_post_data_with_device_id end it "makes a POST request with Google Authenticator code" do verify_post_request google_authenticator_code, nil, login_post_data_with_google_authenticator_code end it "makes a POST request with Yubikey password" do verify_post_request yubikey_password, nil, login_post_data_with_yubikey_password end it "returns a session" do expect(request_login_with_xml "").to eq session end it "raises an exception on HTTP error" do expect { request_login_with_error }.to raise_error LastPass::NetworkError end it "raises an exception when response is not a hash" do expect { request_login_with_ok "not a hash" }.to raise_error LastPass::InvalidResponseError end it "raises an exception on unknown response schema" do expect { request_login_with_xml "" }.to raise_error LastPass::UnknownResponseSchemaError end it "raises an exception on unknown response schema" do expect { request_login_with_xml "" }.to raise_error LastPass::UnknownResponseSchemaError end it "raises an exception on unknown response schema" do expect { request_login_with_xml "" } .to raise_error LastPass::UnknownResponseSchemaError end it "raises an exception on unknown username" do message = "Unknown email address." expect { request_login_with_lastpass_error "unknownemail", message } .to raise_error LastPass::LastPassUnknownUsernameError, message end it "raises an exception on invalid password" do message = "Invalid password!" expect { request_login_with_lastpass_error "unknownpassword", message } .to raise_error LastPass::LastPassInvalidPasswordError, message end it "raises an exception on missing Google Authenticator code" do message = "Google Authenticator authentication required! " + "Upgrade your browser extension so you can enter it." expect { request_login_with_lastpass_error "googleauthrequired", message } .to raise_error LastPass::LastPassIncorrectGoogleAuthenticatorCodeError, message end it "raises an exception on incorrect Google Authenticator code" do message = "Google Authenticator authentication failed!" expect { request_login_with_lastpass_error "googleauthfailed", message } .to raise_error LastPass::LastPassIncorrectGoogleAuthenticatorCodeError, message end it "raises an exception on missing/incorrect Yubikey password" do message = "Your account settings have restricted you from logging in " + "from mobile devices that do not support YubiKey authentication." expect { request_login_with_lastpass_error "otprequired", message } .to raise_error LastPass::LastPassIncorrectYubikeyPasswordError, message end it "raises an exception on unknown LastPass error with a message" do message = "Unknow error message" expect { request_login_with_lastpass_error "Unknown cause", message } .to raise_error LastPass::LastPassUnknownError, message end it "raises an exception on unknown LastPass error without a message" do cause = "Unknown casue" expect { request_login_with_lastpass_error cause } .to raise_error LastPass::LastPassUnknownError, cause end end describe ".fetch" do it "makes a GET request" do expect(web_client = double("web_client")).to receive(:get) .with("https://lastpass.com/getaccts.php?mobile=1&b64=1&hash=0.0&hasplugin=3.0.23&requestsrc=cli", format: :plain, cookies: {"PHPSESSID" => escaped_session_id}) .and_return(http_ok(blob_response)) LastPass::Fetcher.fetch session, web_client end it "returns a blob" do expect(LastPass::Fetcher.fetch session, double("web_client", get: http_ok(blob_response))) .to eq blob end it "raises an exception on HTTP error" do expect { LastPass::Fetcher.fetch session, double("web_client", get: http_error) } .to raise_error LastPass::NetworkError end end describe ".make_key" do it "generates correct keys" do def key iterations LastPass::Fetcher.make_key "postlass@gmail.com", "pl1234567890", iterations end expect(key 1).to eq "C/Bh2SGWxI8JDu54DbbpV8J9wa6pKbesIb9MAXkeF3Y=".decode64 expect(key 5).to eq "pE9goazSCRqnWwcixWM4NHJjWMvB5T15dMhe6ug1pZg=".decode64 expect(key 10).to eq "n9S0SyJdrMegeBHtkxUx8Lzc7wI6aGl+y3/udGmVey8=".decode64 expect(key 50).to eq "GwI8/kNy1NjIfe3Z0VAZfF78938UVuCi6xAL3MJBux0=".decode64 expect(key 100).to eq "piGdSULeHMWiBS3QJNM46M5PIYwQXA6cNS10pLB3Xf8=".decode64 expect(key 500).to eq "OfOUvVnQzB4v49sNh4+PdwIFb9Fr5+jVfWRTf+E2Ghg=".decode64 expect(key 1000).to eq "z7CdwlIkbu0XvcB7oQIpnlqwNGemdrGTBmDKnL9taPg=".decode64 end end describe ".make_hash" do it "generates correct hashes" do def hash iterations LastPass::Fetcher.make_hash "postlass@gmail.com", "pl1234567890", iterations end expect(hash 1).to eq "a1943cfbb75e37b129bbf78b9baeab4ae6dd08225776397f66b8e0c7a913a055" expect(hash 5).to eq "a95849e029a7791cfc4503eed9ec96ab8675c4a7c4e82b00553ddd179b3d8445" expect(hash 10).to eq "0da0b44f5e6b7306f14e92de6d629446370d05afeb1dc07cfcbe25f169170c16" expect(hash 50).to eq "1d5bc0d636da4ad469cefe56c42c2ff71589facb9c83f08fcf7711a7891cc159" expect(hash 100).to eq "82fc12024acb618878ba231a9948c49c6f46e30b5a09c11d87f6d3338babacb5" expect(hash 500).to eq "3139861ae962801b59fc41ff7eeb11f84ca56d810ab490f0d8c89d9d9ab07aa6" expect(hash 1000).to eq "03161354566c396fcd624a424164160e890e96b4b5fa6d942fc6377ab613513b" end end # # Helpers # private def mock_response type, code, body double response: type.new("1.1", code, ""), parsed_response: body end def http_ok body mock_response Net::HTTPOK, 200, body end def http_error body = "" mock_response Net::HTTPNotFound, 404, body end def xml text MultiXml.parse text end def lastpass_error cause, message if message %Q{} else %Q{} end end def request_login_with_lastpass_error cause, message = nil request_login_with_xml lastpass_error cause, message end def request_login_with_xml text request_login_with_ok xml text end def request_login_with_ok response request_login_with_response http_ok response end def request_login_with_error request_login_with_response http_error end def request_login_with_response response LastPass::Fetcher.request_login username, password, key_iteration_count, nil, nil, double("web_client", post: response) end end