lastpass 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ Gemfile.lock
2
+ coverage
3
+ example/credentials.yaml
@@ -0,0 +1 @@
1
+ 1.9.3-p448
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2013 Dmitry Yakimenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ 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, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,4 @@
1
+ all:
2
+ @# TODO: This is a temporary hack for ST3.
3
+ @# Figure out why ST3 doesn't use the environment of the launching process.
4
+ @rbenv exec rake
@@ -0,0 +1,59 @@
1
+ LastPass Ruby API
2
+ =================
3
+
4
+ [![Build Status](https://travis-ci.org/detunized/lastpass-ruby.png?branch=master)](https://travis-ci.org/detunized/lastpass-ruby)
5
+ [![Coverage Status](https://coveralls.io/repos/detunized/lastpass-ruby/badge.png?branch=master)](https://coveralls.io/r/detunized/lastpass-ruby?branch=master)
6
+ [![Code Climate](https://codeclimate.com/github/detunized/lastpass-ruby.png)](https://codeclimate.com/github/detunized/lastpass-ruby)
7
+ [![Dependency Status](https://gemnasium.com/detunized/lastpass-ruby.png)](https://gemnasium.com/detunized/lastpass-ruby)
8
+
9
+ **This is unofficial LastPass API.**
10
+
11
+ This library implements fetching and parsing of LastPass data. The library is
12
+ still in the proof of concept stage and doesn't support all LastPass features
13
+ yet. Only account information (logins, passwords, urls, etc.) is available so
14
+ far.
15
+
16
+ There is a low level API which is used to fetch the data from the LastPass
17
+ server and parse it. Normally this is not the one you would want to use. What
18
+ you want is the `Vault` class which hides all the complexity and exposes all
19
+ the accounts already parsed, decrypted and ready to use. See the example
20
+ program for detail.
21
+
22
+ A quick example of accessing your account information:
23
+
24
+ ```ruby
25
+ require "lastpass.rb"
26
+
27
+ vault = LastPass::Vault.open_remote "username", "password"
28
+ vault.accounts.each do |i|
29
+ puts "#{i.name}: #{i.username}, #{i.password} (#{i.url})"
30
+ end
31
+ ```
32
+
33
+ The blob received from LastPass could be safely stored locally (it's well
34
+ encrypted) and reused later on.
35
+
36
+
37
+ LostPass iOS App
38
+ ----------------
39
+
40
+ There's an iOS app called [LostPass](http://detunized.net/lostpass/) that is
41
+ based on a totally incomplete C++ port of this library. If you are a LastPass
42
+ user it would have made your life much easier if I didn't have to take it down
43
+ from the App Store. Now it's open source and if you have a developer account
44
+ or a jailbroken phone you could build it and install it on the phone. The
45
+ source code is [here](https://github.com/detunized/LostPass).
46
+
47
+
48
+ Contributing
49
+ ------------
50
+
51
+ Contribution in any form and shape is very welcome. Have comments,
52
+ suggestions, patches, pull requests? All of the above are welcome.
53
+
54
+
55
+ License
56
+ -------
57
+
58
+ The library is released under [the MIT
59
+ license](http://www.opensource.org/licenses/mit-license.php).
@@ -0,0 +1,16 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ require "rspec/core/rake_task"
5
+
6
+ task :default => :spec
7
+
8
+ # Spec
9
+ RSpec::Core::RakeTask.new :spec do |task|
10
+ task.rspec_opts = "--format nested --color"
11
+ end
12
+
13
+ # Example
14
+ task :example do
15
+ ruby "-Ilib", "example/example.rb"
16
+ end
@@ -0,0 +1,4 @@
1
+ # Copy this file to credentials.yaml and put correct username/password.
2
+ # There shouldn't be any real credentials in the repo.
3
+ username: account@example.com
4
+ password: correct horse battery staple
@@ -0,0 +1,19 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ # Run via top level rake file:
5
+ # $ rake example
6
+
7
+ require "lastpass"
8
+ require "yaml"
9
+
10
+ credentials = YAML.load_file File.join File.dirname(__FILE__), "credentials.yaml"
11
+
12
+ username = credentials["username"]
13
+ password = credentials["password"]
14
+
15
+ vault = LastPass::Vault.open_remote username, password
16
+
17
+ vault.accounts.each_with_index do |i, index|
18
+ puts "#{index + 1}: #{i.id} #{i.name} #{i.username} #{i.password} #{i.url} #{i.group}}"
19
+ end
@@ -0,0 +1,29 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ $:.push File.expand_path("../lib", __FILE__)
5
+ require "lastpass/version"
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "lastpass"
9
+ s.version = LastPass::VERSION
10
+ s.licenses = ["MIT"]
11
+ s.authors = ["Dmitry Yakimenko"]
12
+ s.email = "detunized@gmail.com"
13
+ s.homepage = "https://github.com/detunized/lastpass-ruby"
14
+ s.summary = "Unofficial LastPass API"
15
+ s.description = "Unofficial LastPass API"
16
+
17
+ s.required_ruby_version = ">= 1.9.3"
18
+
19
+ s.add_dependency "httparty", "~> 0.12.0"
20
+ s.add_dependency "pbkdf2", "~> 0.1.0"
21
+
22
+ s.add_development_dependency "rake", "~> 10.0.0"
23
+ s.add_development_dependency "rspec", "~> 2.14.0"
24
+ s.add_development_dependency "coveralls", "~> 0.7.0"
25
+
26
+ s.files = `git ls-files`.split "\n"
27
+ s.test_files = `git ls-files spec`.split "\n"
28
+ s.require_paths = ["lib"]
29
+ end
@@ -0,0 +1,18 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ require "base64"
5
+ require "httparty"
6
+ require "openssl"
7
+ require "pbkdf2"
8
+ require "stringio"
9
+
10
+ require "lastpass/account"
11
+ require "lastpass/blob"
12
+ require "lastpass/chunk"
13
+ require "lastpass/exceptions"
14
+ require "lastpass/fetcher"
15
+ require "lastpass/parser"
16
+ require "lastpass/session"
17
+ require "lastpass/vault"
18
+ require "lastpass/version"
@@ -0,0 +1,22 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ module LastPass
5
+ class Account
6
+ attr_reader :id,
7
+ :name,
8
+ :username,
9
+ :password,
10
+ :url,
11
+ :group
12
+
13
+ def initialize id, name, username, password, url, group
14
+ @id = id
15
+ @name = name
16
+ @username = username
17
+ @password = password
18
+ @url = url
19
+ @group = group
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ module LastPass
5
+ class Blob
6
+ attr_reader :bytes,
7
+ :key_iteration_count
8
+
9
+ def initialize bytes, key_iteration_count
10
+ @bytes = bytes
11
+ @key_iteration_count = key_iteration_count
12
+ end
13
+
14
+ def encryption_key username, password
15
+ Fetcher.make_key username, password, key_iteration_count
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ module LastPass
5
+ class Chunk
6
+ attr_reader :id,
7
+ :payload
8
+
9
+ def initialize id, payload
10
+ @id = id
11
+ @payload = payload
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,33 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ module LastPass
5
+ # Base class for all errors, should not be raised
6
+ class Error < StandardError; end
7
+
8
+ #
9
+ # Generic errors
10
+ #
11
+
12
+ # Something went wrong with the network
13
+ class NetworkError < Error; end
14
+
15
+ # Server responded with something we don't understand
16
+ class InvalidResponse < Error; end
17
+
18
+ # Server responded with XML we don't understand
19
+ class UnknownResponseSchema < Error; end
20
+
21
+ #
22
+ # LastPass returned errors
23
+ #
24
+
25
+ # LastPass error: unknown username
26
+ class LastPassUnknownUsername < Error; end
27
+
28
+ # LastPass error: invalid password
29
+ class LastPassInvalidPassword < Error; end
30
+
31
+ # LastPass error we don't know about
32
+ class LastPassUnknownError < Error; end
33
+ end
@@ -0,0 +1,125 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ module LastPass
5
+ class Fetcher
6
+ def self.login username, password
7
+ key_iteration_count = request_iteration_count username
8
+ request_login username, password, key_iteration_count
9
+ end
10
+
11
+ def self.fetch session, web_client = HTTParty
12
+ response = web_client.get "https://lastpass.com/getaccts.php?mobile=1&b64=1&hash=0.0",
13
+ format: :plain,
14
+ cookies: {"PHPSESSID" => URI.encode(session.id)}
15
+
16
+ raise NetworkError unless response.response.is_a? Net::HTTPOK
17
+
18
+ Blob.new decode_blob(response.parsed_response), session.key_iteration_count
19
+ end
20
+
21
+ def self.request_iteration_count username, web_client = HTTParty
22
+ response = web_client.post "https://lastpass.com/iterations.php",
23
+ query: {email: username}
24
+
25
+ raise NetworkError unless response.response.is_a? Net::HTTPOK
26
+
27
+ begin
28
+ count = Integer response.parsed_response
29
+ rescue ArgumentError
30
+ raise InvalidResponse, "Key iteration count is invalid"
31
+ end
32
+
33
+ raise InvalidResponse, "Key iteration count is not positive" unless count > 0
34
+
35
+ count
36
+ end
37
+
38
+ def self.request_login username, password, key_iteration_count, web_client = HTTParty
39
+ response = web_client.post "https://lastpass.com/login.php",
40
+ format: :xml,
41
+ body: {
42
+ method: "mobile",
43
+ web: 1,
44
+ xml: 1,
45
+ username: username,
46
+ hash: make_hash(username, password, key_iteration_count),
47
+ iterations: key_iteration_count
48
+ }
49
+
50
+ raise NetworkError unless response.response.is_a? Net::HTTPOK
51
+
52
+ parsed_response = response.parsed_response
53
+ raise InvalidResponse unless parsed_response.is_a? Hash
54
+
55
+ create_session parsed_response, key_iteration_count or
56
+ raise login_error parsed_response
57
+ end
58
+
59
+ def self.create_session parsed_response, key_iteration_count
60
+ ok = parsed_response["ok"]
61
+ if ok.is_a? Hash
62
+ session_id = ok["sessionid"]
63
+ if session_id.is_a? String
64
+ return Session.new session_id, key_iteration_count
65
+ end
66
+ end
67
+
68
+ nil
69
+ end
70
+
71
+ def self.login_error parsed_response
72
+ error = (parsed_response["response"] || {})["error"]
73
+ return UnknownResponseSchema unless error.is_a? Hash
74
+
75
+ exceptions = {
76
+ "unknownemail" => LastPassUnknownUsername,
77
+ "unknownpassword" => LastPassInvalidPassword,
78
+ }
79
+
80
+ cause = error["cause"]
81
+ message = error["message"]
82
+
83
+ if cause
84
+ (exceptions[cause] || LastPassUnknownError).new message || cause
85
+ else
86
+ InvalidResponse.new message
87
+ end
88
+ end
89
+
90
+ def self.decode_blob blob
91
+ # TODO: Check for invalid base64
92
+ Base64.decode64 blob
93
+ end
94
+
95
+ def self.make_key username, password, key_iteration_count
96
+ if key_iteration_count == 1
97
+ Digest::SHA256.digest username + password
98
+ else
99
+ PBKDF2
100
+ .new(password: password,
101
+ salt: username,
102
+ iterations: key_iteration_count,
103
+ key_length: 32)
104
+ .bin_string
105
+ .force_encoding "BINARY"
106
+ end
107
+ end
108
+
109
+ def self.make_hash username, password, key_iteration_count
110
+ if key_iteration_count == 1
111
+ Digest::SHA256.hexdigest Digest.hexencode(make_key(username, password, 1)) + password
112
+ else
113
+ PBKDF2
114
+ .new(password: make_key(username, password, key_iteration_count),
115
+ salt: password,
116
+ iterations: 1,
117
+ key_length: 32)
118
+ .hex_string
119
+ end
120
+ end
121
+
122
+ # Can't instantiate Fetcher
123
+ private_class_method :new
124
+ end
125
+ end
@@ -0,0 +1,184 @@
1
+ # Copyright (C) 2013 Dmitry Yakimenko (detunized@gmail.com).
2
+ # Licensed under the terms of the MIT license. See LICENCE for details.
3
+
4
+ module LastPass
5
+ class Parser
6
+ # Splits the blob into chucks grouped by kind.
7
+ def self.extract_chunks blob
8
+ chunks = Hash.new { |hash, key| hash[key] = [] }
9
+
10
+ StringIO.open blob.bytes do |stream|
11
+ while !stream.eof?
12
+ chunk = read_chunk stream
13
+ chunks[chunk.id] << chunk
14
+ end
15
+ end
16
+
17
+ chunks
18
+ end
19
+
20
+ # Parses an account chunk, decrypts and creates an Account object.
21
+ # TODO: See if this should be part of Account class.
22
+ def self.parse_account chunk, encryption_key
23
+ StringIO.open chunk.payload do |io|
24
+ id = read_item io
25
+ name = decode_aes256_auto read_item(io), encryption_key
26
+ group = decode_aes256_auto read_item(io), encryption_key
27
+ url = decode_hex read_item io
28
+ 3.times { skip_item io }
29
+ username = decode_aes256_auto read_item(io), encryption_key
30
+ password = decode_aes256_auto read_item(io), encryption_key
31
+
32
+ Account.new id, name, username, password, url, group
33
+ end
34
+ end
35
+
36
+ # Reads one chunk from a stream and creates a Chunk object with the data read.
37
+ def self.read_chunk stream
38
+ # LastPass blob chunk is made up of 4-byte ID,
39
+ # big endian 4-byte size and payload of that size.
40
+ #
41
+ # Example:
42
+ # 0000: "IDID"
43
+ # 0004: 4
44
+ # 0008: 0xDE 0xAD 0xBE 0xEF
45
+ # 000C: --- Next chunk ---
46
+ Chunk.new read_id(stream), read_payload(stream, read_size(stream))
47
+ end
48
+
49
+ # Reads an item from a stream and returns it as a string of bytes.
50
+ def self.read_item stream
51
+ # An item in an itemized chunk is made up of the
52
+ # big endian size and the payload of that size.
53
+ #
54
+ # Example:
55
+ # 0000: 4
56
+ # 0004: 0xDE 0xAD 0xBE 0xEF
57
+ # 0008: --- Next item ---
58
+ read_payload stream, read_size(stream)
59
+ end
60
+
61
+ # Skips an item in a stream.
62
+ def self.skip_item stream
63
+ read_item stream
64
+ end
65
+
66
+ # Reads a chunk ID from a stream.
67
+ def self.read_id stream
68
+ stream.read 4
69
+ end
70
+
71
+ # Reads a chunk or an item ID.
72
+ def self.read_size stream
73
+ read_uint32 stream
74
+ end
75
+
76
+ # Reads a payload of a given size from a stream.
77
+ def self.read_payload stream, size
78
+ stream.read size
79
+ end
80
+
81
+ # Reads an unsigned 32 bit integer from a stream.
82
+ def self.read_uint32 stream
83
+ stream.read(4).unpack("N").first
84
+ end
85
+
86
+ # Decodes a hex encoded string into raw bytes.
87
+ def self.decode_hex data
88
+ raise ArgumentError, "Input length must be multple of 2" unless data.size % 2 == 0
89
+ raise ArgumentError, "Input contains invalid characters" unless data =~ /^[0-9a-f]*$/i
90
+
91
+ data.scan(/../).map { |i| i.to_i 16 }.pack "c*"
92
+ end
93
+
94
+ # Decodes a base64 encoded string into raw bytes.
95
+ def self.decode_base64 data
96
+ # TODO: Check for input validity!
97
+ Base64.decode64 data
98
+ end
99
+
100
+ # Guesses AES encoding/cipher from the length of the data.
101
+ # Possible combinations are:
102
+ # - ciphers: AES-256 EBC, AES-256 CBC
103
+ # - encodings: plain, base64
104
+ def self.decode_aes256_auto data, encryption_key
105
+ length = data.length
106
+ length16 = length % 16
107
+ length64 = length % 64
108
+
109
+ if length == 0
110
+ ""
111
+ elsif length16 == 0
112
+ decode_aes256_ecb_plain data, encryption_key
113
+ elsif length64 == 0 || length64 == 24 || length64 == 44
114
+ decode_aes256_ecb_base64 data, encryption_key
115
+ elsif length16 == 1
116
+ decode_aes256_cbc_plain data, encryption_key
117
+ elsif length64 == 6 || length64 == 26 || length64 == 50
118
+ decode_aes256_cbc_base64 data, encryption_key
119
+ else
120
+ raise RuntimeError, "'#{data.inspect}' doesn't seem to be AES-256 encrypted"
121
+ end
122
+ end
123
+
124
+ # Decrypts AES-256 ECB bytes.
125
+ def self.decode_aes256_ecb_plain data, encryption_key
126
+ if data.empty?
127
+ ""
128
+ else
129
+ decode_aes256 :ecb, "", data, encryption_key
130
+ end
131
+ end
132
+
133
+ # Decrypts base64 encoded AES-256 ECB bytes.
134
+ def self.decode_aes256_ecb_base64 data, encryption_key
135
+ decode_aes256_ecb_plain decode_base64(data), encryption_key
136
+ end
137
+
138
+ # Decrypts AES-256 CBC bytes.
139
+ def self.decode_aes256_cbc_plain data, encryption_key
140
+ if data.empty?
141
+ ""
142
+ else
143
+ # LastPass AES-256/CBC encryted string starts with an "!".
144
+ # Next 16 bytes are the IV for the cipher.
145
+ # And the rest is the encrypted payload.
146
+
147
+ # TODO: Check for input validity!
148
+ decode_aes256 :cbc,
149
+ data[1, 16],
150
+ data[17..-1],
151
+ encryption_key
152
+ end
153
+ end
154
+
155
+ # Decrypts base64 encoded AES-256 CBC bytes.
156
+ def self.decode_aes256_cbc_base64 data, encryption_key
157
+ if data.empty?
158
+ ""
159
+ else
160
+ # LastPass AES-256/CBC/base64 encryted string starts with an "!".
161
+ # Next 24 bytes are the base64 encoded IV for the cipher.
162
+ # Then comes the "|".
163
+ # And the rest is the base64 encoded encrypted payload.
164
+
165
+ # TODO: Check for input validity!
166
+ decode_aes256 :cbc,
167
+ decode_base64(data[1, 24]),
168
+ decode_base64(data[26..-1]),
169
+ encryption_key
170
+ end
171
+ end
172
+
173
+ # Decrypt AES-256 bytes.
174
+ # Allowed ciphers are: :ecb, :cbc.
175
+ # If for :ecb iv is not used and should be set to "".
176
+ def self.decode_aes256 cipher, iv, data, encryption_key
177
+ aes = OpenSSL::Cipher::Cipher.new "aes-256-#{cipher}"
178
+ aes.decrypt
179
+ aes.key = encryption_key
180
+ aes.iv = iv
181
+ aes.update(data) + aes.final
182
+ end
183
+ end
184
+ end