lastpass 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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