# 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