rmega 0.1.7 → 0.2.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.
- checksums.yaml +4 -4
- data/.travis.yml +6 -0
- data/CHANGELOG.md +16 -0
- data/README.md +1 -1
- data/TODO.md +3 -5
- data/bin/rmega-dl +47 -0
- data/bin/rmega-up +31 -0
- data/lib/rmega.rb +35 -3
- data/lib/rmega/api_response.rb +80 -0
- data/lib/rmega/cli.rb +121 -0
- data/lib/rmega/crypto.rb +20 -0
- data/lib/rmega/crypto/aes_cbc.rb +46 -0
- data/lib/rmega/crypto/aes_ctr.rb +15 -84
- data/lib/rmega/crypto/aes_ecb.rb +25 -0
- data/lib/rmega/crypto/rsa.rb +21 -12
- data/lib/rmega/errors.rb +3 -51
- data/lib/rmega/loggable.rb +0 -3
- data/lib/rmega/net.rb +56 -0
- data/lib/rmega/nodes/deletable.rb +0 -3
- data/lib/rmega/nodes/downloadable.rb +73 -30
- data/lib/rmega/nodes/expandable.rb +14 -10
- data/lib/rmega/nodes/factory.rb +30 -17
- data/lib/rmega/nodes/file.rb +0 -4
- data/lib/rmega/nodes/folder.rb +4 -14
- data/lib/rmega/nodes/inbox.rb +0 -2
- data/lib/rmega/nodes/node.rb +48 -25
- data/lib/rmega/nodes/node_key.rb +44 -0
- data/lib/rmega/nodes/root.rb +0 -4
- data/lib/rmega/nodes/trash.rb +0 -3
- data/lib/rmega/nodes/uploadable.rb +42 -33
- data/lib/rmega/not_inspectable.rb +10 -0
- data/lib/rmega/options.rb +22 -5
- data/lib/rmega/pool.rb +18 -7
- data/lib/rmega/progress.rb +53 -13
- data/lib/rmega/session.rb +125 -52
- data/lib/rmega/storage.rb +25 -21
- data/lib/rmega/utils.rb +23 -183
- data/lib/rmega/version.rb +2 -1
- data/rmega.gemspec +3 -5
- data/spec/integration/file_download_spec.rb +14 -32
- data/spec/integration/file_integrity_spec.rb +41 -0
- data/spec/integration/file_upload_spec.rb +11 -57
- data/spec/integration/folder_download_spec.rb +17 -0
- data/spec/integration/folder_operations_spec.rb +30 -30
- data/spec/integration/login_spec.rb +3 -3
- data/spec/integration/resume_download_spec.rb +53 -0
- data/spec/integration_spec_helper.rb +9 -4
- data/spec/rmega/lib/cli_spec.rb +12 -0
- data/spec/rmega/lib/session_spec.rb +31 -0
- data/spec/rmega/lib/storage_spec.rb +27 -0
- data/spec/rmega/lib/utils_spec.rb +16 -78
- data/spec/spec_helper.rb +1 -4
- metadata +30 -40
- data/lib/rmega/crypto/aes.rb +0 -35
- data/lib/rmega/crypto/crypto.rb +0 -107
- data/lib/rmega/crypto/rsa_mega.js +0 -455
- data/spec/rmega/lib/crypto/aes_spec.rb +0 -12
- data/spec/rmega/lib/crypto/crypto_spec.rb +0 -27
data/lib/rmega/crypto/aes_ctr.rb
CHANGED
@@ -1,94 +1,25 @@
|
|
1
|
-
require 'rmega/utils'
|
2
|
-
require 'rmega/crypto/aes'
|
3
|
-
|
4
1
|
module Rmega
|
5
2
|
module Crypto
|
6
3
|
module AesCtr
|
7
|
-
|
8
|
-
|
9
|
-
def decrypt(key, nonce, data)
|
10
|
-
raise "invalid nonce" if nonce.size != 4 or !nonce.respond_to?(:pack)
|
11
|
-
raise "invalid key" if key.size != 4 or !key.respond_to?(:pack)
|
12
|
-
|
13
|
-
nonce = nonce.dup
|
14
|
-
|
15
|
-
mac = [nonce[0], nonce[1], nonce[0], nonce[1]]
|
16
|
-
enc = nil
|
17
|
-
a32 = Utils.str_to_a32 data
|
18
|
-
len = a32.size - 3
|
19
|
-
last_i = 0
|
20
|
-
|
21
|
-
(0..len).step(4) do |i|
|
22
|
-
enc = Aes.encrypt key, nonce
|
23
|
-
4.times do |m|
|
24
|
-
a32[i+m] = (a32[i+m] || 0) ^ (enc[m] || 0)
|
25
|
-
mac[m] = (mac[m] || 0) ^ (a32[i+m] || 0)
|
26
|
-
end
|
27
|
-
mac = Aes.encrypt key, mac
|
28
|
-
nonce[3] += 1
|
29
|
-
nonce[2] += 1 if nonce[3] == 0
|
30
|
-
last_i = i + 4
|
31
|
-
end
|
32
|
-
|
33
|
-
if last_i < a32.size
|
34
|
-
v = [0, 0, 0, 0]
|
35
|
-
(last_i..a32.size - 1).step(1) { |m| v[m-last_i] = a32[m] || 0 }
|
36
|
-
|
37
|
-
enc = Aes.encrypt key, nonce
|
38
|
-
4.times { |m| v[m] = v[m] ^ enc[m] }
|
39
|
-
|
40
|
-
j = data.size & 15
|
41
|
-
m = Utils.str_to_a32 Array.new(j+1).join(255.chr)+Array.new(17-j).join(0.chr)
|
42
|
-
|
43
|
-
4.times { |x| mac[x] = mac[x] ^ (v[x] & m[x]) }
|
44
|
-
|
45
|
-
mac = Aes.encrypt key, mac
|
46
|
-
|
47
|
-
(last_i..a32.size - 1).step(1) { |j| a32[j] = v[j - last_i] || 0 }
|
48
|
-
end
|
49
|
-
|
50
|
-
decrypted_data = Utils.a32_to_str(a32, data.size)
|
51
|
-
|
52
|
-
{data: decrypted_data, mac: mac}
|
4
|
+
def aes_ctr_cipher
|
5
|
+
OpenSSL::Cipher::AES.new(128, :CTR)
|
53
6
|
end
|
54
7
|
|
55
|
-
def
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
ab32 = Utils.str_to_a32 data
|
62
|
-
len = ab32.size - 3
|
63
|
-
enc = nil
|
64
|
-
last_i = 0
|
65
|
-
|
66
|
-
(0..len).step(4) do |i|
|
67
|
-
4.times { |x| mac[x] = mac[x] ^ (ab32[i+x] || 0) }
|
68
|
-
mac = Aes.encrypt key, mac
|
69
|
-
enc = Aes.encrypt key, ctr
|
70
|
-
4.times { |x| ab32[i+x] = (ab32[i+x] || 0) ^ (enc[x] || 0) }
|
71
|
-
ctr[3] += 1
|
72
|
-
ctr[2] += 1 if ctr[3].zero?
|
73
|
-
last_i = i + 4
|
74
|
-
end
|
75
|
-
|
76
|
-
i = last_i
|
77
|
-
|
78
|
-
if i < ab32.size
|
79
|
-
v = [0, 0, 0, 0]
|
80
|
-
(i..ab32.size - 1).step(1) { |j| v[j - i] = ab32[j] || 0 }
|
81
|
-
4.times { |x| mac[x] = mac[x] ^ v[x] }
|
82
|
-
mac = Aes.encrypt key, mac
|
83
|
-
enc = Aes.encrypt key, ctr
|
84
|
-
4.times { |x| v[x] = v[x] ^ enc[x] }
|
85
|
-
(i..ab32.size - 1).step(1) { |j| ab32[j] = v[j - i] || 0 }
|
86
|
-
end
|
87
|
-
|
88
|
-
decrypted_data = Utils.a32_to_str ab32, data.size
|
89
|
-
{data: decrypted_data, mac: mac}
|
8
|
+
def aes_ctr_decrypt(key, data, iv)
|
9
|
+
cipher = aes_ctr_cipher
|
10
|
+
cipher.decrypt
|
11
|
+
cipher.iv = iv
|
12
|
+
cipher.key = key
|
13
|
+
return cipher.update(data) + cipher.final
|
90
14
|
end
|
91
15
|
|
16
|
+
def aes_ctr_encrypt(key, data, iv)
|
17
|
+
cipher = aes_ctr_cipher
|
18
|
+
cipher.encrypt
|
19
|
+
cipher.iv = iv
|
20
|
+
cipher.key = key
|
21
|
+
return cipher.update(data) + cipher.final
|
22
|
+
end
|
92
23
|
end
|
93
24
|
end
|
94
25
|
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Rmega
|
2
|
+
module Crypto
|
3
|
+
module AesEcb
|
4
|
+
def aes_ecb_cipher
|
5
|
+
OpenSSL::Cipher::AES.new(128, :ECB)
|
6
|
+
end
|
7
|
+
|
8
|
+
def aes_ecb_encrypt(key, data)
|
9
|
+
cipher = aes_ecb_cipher
|
10
|
+
cipher.encrypt
|
11
|
+
cipher.padding = 0
|
12
|
+
cipher.key = key
|
13
|
+
return cipher.update(data) + cipher.final
|
14
|
+
end
|
15
|
+
|
16
|
+
def aes_ecb_decrypt(key, data)
|
17
|
+
cipher = aes_ecb_cipher
|
18
|
+
cipher.decrypt
|
19
|
+
cipher.padding = 0
|
20
|
+
cipher.key = key
|
21
|
+
return cipher.update(data) + cipher.final
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/rmega/crypto/rsa.rb
CHANGED
@@ -1,20 +1,29 @@
|
|
1
|
-
require 'execjs'
|
2
|
-
|
3
1
|
module Rmega
|
4
2
|
module Crypto
|
5
3
|
module Rsa
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
4
|
+
def powm(b, p, m)
|
5
|
+
if p == 1
|
6
|
+
b % m
|
7
|
+
elsif (p & 0x1) == 0
|
8
|
+
t = powm(b, p >> 1, m)
|
9
|
+
(t * t) % m
|
10
|
+
else
|
11
|
+
(b * powm(b, p-1, m)) % m
|
12
|
+
end
|
14
13
|
end
|
15
14
|
|
16
|
-
def
|
17
|
-
|
15
|
+
def rsa_decrypt(m, pqdu)
|
16
|
+
p, q, d, u = pqdu
|
17
|
+
if p && q && u
|
18
|
+
m1 = powm(m, d % (p - 1), p)
|
19
|
+
m2 = powm(m, d % (q - 1), q)
|
20
|
+
h = m2 - m1
|
21
|
+
h = h + q if h < 0
|
22
|
+
h = h * u % q
|
23
|
+
h * p + m1
|
24
|
+
else
|
25
|
+
pow_m(m, d, p * q)
|
26
|
+
end
|
18
27
|
end
|
19
28
|
end
|
20
29
|
end
|
data/lib/rmega/errors.rb
CHANGED
@@ -1,55 +1,7 @@
|
|
1
1
|
module Rmega
|
2
|
-
|
3
|
-
|
4
|
-
# Check out the error codes list at https://mega.co.nz/#doc (section 11)
|
5
|
-
CODES = {
|
6
|
-
-1 => 'An internal error has occurred. Please submit a bug report, detailing the exact circumstances in which this error occurred.',
|
7
|
-
-2 => 'You have passed invalid arguments to this command.',
|
8
|
-
-3 => 'A temporary congestion or server malfunction prevented your request from being processed. No data was altered. Retry. Retries must be spaced with exponential backoff.',
|
9
|
-
-4 => 'You have exceeded your command weight per time quota. Please wait a few seconds, then try again (this should never happen in sane real-life applications).',
|
10
|
-
-5 => 'The upload failed. Please restart it from scratch.',
|
11
|
-
-6 => 'Too many concurrent IP addresses are accessing this upload target URL.',
|
12
|
-
-7 => 'The upload file packet is out of range or not starting and ending on a chunk boundary.',
|
13
|
-
-8 => 'The upload target URL you are trying to access has expired. Please request a fresh one.',
|
14
|
-
-9 => 'Object (typically, node or user) not found',
|
15
|
-
-10 => 'Circular linkage attempted',
|
16
|
-
-11 => 'Access violation (e.g., trying to write to a read-only share)',
|
17
|
-
-12 => 'Trying to create an object that already exists',
|
18
|
-
-13 => 'Trying to access an incomplete resource',
|
19
|
-
-14 => 'A decryption operation failed (never returned by the API)',
|
20
|
-
-15 => 'Invalid or expired user session, please relogin',
|
21
|
-
-16 => 'User blocked',
|
22
|
-
-17 => 'Request over quota',
|
23
|
-
-18 => 'Resource temporarily not available, please try again later',
|
24
|
-
-19 => 'Too many connections on this resource',
|
25
|
-
-20 => 'Write failed',
|
26
|
-
-21 => 'Read failed',
|
27
|
-
-22 => 'Invalid application key; request not processed',
|
28
|
-
}.freeze
|
29
|
-
|
30
|
-
class ServerError < StandardError
|
31
|
-
attr_reader :code, :message
|
32
|
-
attr_accessor :temporary
|
33
|
-
|
34
|
-
def initialize(value, opts = {})
|
35
|
-
if msg = Errors::CODES[value.to_i]
|
36
|
-
@code = value.to_i
|
37
|
-
@message = msg || "Error code #{@code}"
|
38
|
-
else
|
39
|
-
@message = value
|
40
|
-
end
|
41
|
-
|
42
|
-
self.temporary = opts[:temporary] || false
|
43
|
-
|
44
|
-
super(@message)
|
45
|
-
end
|
46
|
-
|
47
|
-
def temporary?
|
48
|
-
self.temporary || [-3, -18, -6].include?(@code)
|
49
|
-
end
|
50
|
-
end
|
2
|
+
class ServerError < StandardError
|
51
3
|
end
|
52
4
|
|
53
|
-
|
54
|
-
|
5
|
+
class TemporaryServerError < StandardError
|
6
|
+
end
|
55
7
|
end
|
data/lib/rmega/loggable.rb
CHANGED
data/lib/rmega/net.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
module Rmega
|
2
|
+
module Net
|
3
|
+
include Loggable
|
4
|
+
include Options
|
5
|
+
|
6
|
+
def survive(retries = options.max_retries, &block)
|
7
|
+
yield
|
8
|
+
rescue ServerError
|
9
|
+
raise
|
10
|
+
rescue Exception => error
|
11
|
+
retries -= 1
|
12
|
+
raise(error) if retries < 0
|
13
|
+
logger.debug("[#{error.class}] #{error.message}. #{retries} attempt(s) left.")
|
14
|
+
sleep(options.retry_interval)
|
15
|
+
retry
|
16
|
+
end
|
17
|
+
|
18
|
+
def http_get_content(url)
|
19
|
+
uri = URI(url)
|
20
|
+
req = ::Net::HTTP::Get.new(uri.request_uri)
|
21
|
+
return send_http_request(uri, req).body
|
22
|
+
end
|
23
|
+
|
24
|
+
def http_post(url, data)
|
25
|
+
uri = URI(url)
|
26
|
+
req = ::Net::HTTP::Post.new(uri.request_uri)
|
27
|
+
req.body = data
|
28
|
+
logger.debug("REQ POST #{url} #{cut_string(data)}")
|
29
|
+
response = send_http_request(uri, req)
|
30
|
+
logger.debug("REP #{response.code} #{cut_string(response.body)}")
|
31
|
+
return response
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def send_http_request(uri, req)
|
37
|
+
http = ::Net::HTTP.new(uri.host, uri.port)
|
38
|
+
http.use_ssl = true if uri.scheme == 'https'
|
39
|
+
apply_http_options(http)
|
40
|
+
return http.request(req)
|
41
|
+
end
|
42
|
+
|
43
|
+
def apply_http_options(http)
|
44
|
+
http.proxy_from_env = false if options.http_proxy_address
|
45
|
+
|
46
|
+
options.marshal_dump.each do |name, value|
|
47
|
+
setter_method = name.to_s.split('http_')[1]
|
48
|
+
http.__send__("#{setter_method}=", value) if setter_method and value
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def cut_string(string, max = 50)
|
53
|
+
string.size <= max ? string : string[0..max-1]+"..."
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -1,17 +1,45 @@
|
|
1
|
-
require 'rmega/pool'
|
2
|
-
require 'rmega/utils'
|
3
|
-
|
4
1
|
module Rmega
|
5
2
|
module Nodes
|
6
3
|
module Downloadable
|
4
|
+
include Net
|
5
|
+
include Options
|
7
6
|
|
8
7
|
# Creates the local file allocating filesize-n bytes (of /dev/zero) for it.
|
9
8
|
# Opens the local file to start writing from the beginning of it.
|
10
9
|
def allocate(path)
|
11
|
-
|
12
|
-
|
10
|
+
unless allocated?(path)
|
11
|
+
`dd if=/dev/zero of="#{path}" bs=1 count=0 seek=#{filesize} > /dev/null 2>&1`
|
12
|
+
raise "Unable to allocate space for file #{path}" if ::File.size(path) != filesize
|
13
|
+
end
|
14
|
+
|
15
|
+
@file = ::File.open(path, 'r+b')
|
16
|
+
@file.rewind
|
17
|
+
end
|
18
|
+
|
19
|
+
def file_io_synchronize(&block)
|
20
|
+
@file_io_mutex ||= Mutex.new
|
21
|
+
@file_io_mutex.synchronize(&block)
|
22
|
+
end
|
23
|
+
|
24
|
+
def allocated?(path)
|
25
|
+
::File.exists?(path) and ::File.size(path) == filesize
|
26
|
+
end
|
27
|
+
|
28
|
+
# Writes a buffer in the local file, starting from the start-n byte.
|
29
|
+
def write_chunk(start, buffer)
|
30
|
+
file_io_synchronize do
|
31
|
+
@file.seek(start)
|
32
|
+
@file.write(buffer)
|
33
|
+
end
|
34
|
+
end
|
13
35
|
|
14
|
-
|
36
|
+
def read_chunk(start, size)
|
37
|
+
file_io_synchronize do
|
38
|
+
@file.seek(start)
|
39
|
+
data = @file.read(size)
|
40
|
+
@file.seek(start)
|
41
|
+
return (data == "\x0"*size) ? nil : data
|
42
|
+
end
|
15
43
|
end
|
16
44
|
|
17
45
|
# Downloads a part of the remote file, starting from the start-n byte
|
@@ -19,52 +47,67 @@ module Rmega
|
|
19
47
|
def download_chunk(start, size)
|
20
48
|
stop = start + size - 1
|
21
49
|
url = "#{storage_url}/#{start}-#{stop}"
|
22
|
-
|
50
|
+
|
51
|
+
survive do
|
52
|
+
data = http_get_content(url)
|
53
|
+
raise("Unexpected data length") if data.size != size
|
54
|
+
return data
|
55
|
+
end
|
23
56
|
end
|
24
57
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
file.write(buffer)
|
58
|
+
def decrypt_chunk(start, data)
|
59
|
+
iv = @node_key.ctr_nonce + [start/0x1000000000, start/0x10].pack('l>*')
|
60
|
+
return aes_ctr_decrypt(@node_key.aes_key, data, iv)
|
29
61
|
end
|
30
62
|
|
31
|
-
def
|
32
|
-
|
33
|
-
|
34
|
-
decrypt_key = [k[0] ^ k[4], k[1] ^ k[5], k[2] ^ k[6], k[3] ^ k[7]]
|
35
|
-
Crypto::AesCtr.decrypt(decrypt_key, nonce, encrypted_buffer)[:data]
|
63
|
+
def calculate_chunck_mac(data)
|
64
|
+
mac_iv = @node_key.ctr_nonce * 2
|
65
|
+
return aes_cbc_mac(@node_key.aes_key, data, mac_iv)
|
36
66
|
end
|
37
67
|
|
38
68
|
def download(path)
|
39
69
|
path = ::File.expand_path(path)
|
40
70
|
path = Dir.exists?(path) ? ::File.join(path, name) : path
|
41
71
|
|
42
|
-
|
43
|
-
|
72
|
+
progress = Progress.new(filesize, caption: 'Download')
|
44
73
|
pool = Pool.new
|
45
|
-
write_mutex = Mutex.new
|
46
|
-
file = allocate(path)
|
47
74
|
|
48
|
-
|
75
|
+
@resumed_download = allocated?(path)
|
76
|
+
allocate(path)
|
77
|
+
@node_key = NodeKey.load(decrypted_file_key)
|
49
78
|
|
50
|
-
|
51
|
-
pool.defer do
|
52
|
-
encrypted_buffer = download_chunk(start, size)
|
79
|
+
chunk_macs = {}
|
53
80
|
|
54
|
-
|
55
|
-
|
81
|
+
each_chunk do |start, size|
|
82
|
+
pool.process do
|
83
|
+
data = @resumed_download ? read_chunk(start, size) : nil
|
84
|
+
|
85
|
+
if data
|
86
|
+
chunk_macs[start] = calculate_chunck_mac(data) if options.file_integrity_check
|
87
|
+
progress.increment(size, real: false)
|
88
|
+
else
|
89
|
+
data = decrypt_chunk(start, download_chunk(start, size))
|
90
|
+
chunk_macs[start] = calculate_chunck_mac(data) if options.file_integrity_check
|
91
|
+
write_chunk(start, data)
|
56
92
|
progress.increment(size)
|
57
|
-
write_chunk(file, start, clean_buffer)
|
58
93
|
end
|
59
94
|
end
|
60
95
|
end
|
61
96
|
|
62
97
|
# waits for the last running threads to finish
|
63
|
-
pool.
|
98
|
+
pool.shutdown
|
99
|
+
|
100
|
+
if options.file_integrity_check
|
101
|
+
file_mac = aes_cbc_mac(@node_key.aes_key, chunk_macs.sort.map(&:last).join, "\x0"*16)
|
102
|
+
|
103
|
+
if Utils.compact_to_8_bytes(file_mac) != @node_key.meta_mac
|
104
|
+
raise("Checksum failed. File corrupted?")
|
105
|
+
end
|
106
|
+
end
|
64
107
|
|
65
|
-
|
108
|
+
return nil
|
66
109
|
ensure
|
67
|
-
file.close rescue nil
|
110
|
+
@file.close rescue nil
|
68
111
|
end
|
69
112
|
end
|
70
113
|
end
|