diff --git a/src/backend/drivers/mega/crypto.test.ts b/src/backend/drivers/mega/crypto.test.ts new file mode 100644 index 00000000..3b17dc40 --- /dev/null +++ b/src/backend/drivers/mega/crypto.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict" +import crypto from "node:crypto" +import test from "node:test" +import { + aesCbcDecrypt, + aesCbcEncrypt, + aesEcbDecrypt, + aesEcbEncrypt, + b64ToBytes, + bytesToB64Url, + decodeMegaPrivateKey, + decryptSessionId, + encodeMpi, + nodeCipherKey, + prepareKey, + prepareKeyV2, + stringhash, + utf8ToBytes, +} from "./crypto" + +function nodeAes( + mode: "ecb" | "cbc", + operation: "encrypt" | "decrypt", + key: Buffer, + data: Buffer, + iv?: Buffer, +): Buffer { + const factory = + operation === "encrypt" ? crypto.createCipheriv : crypto.createDecipheriv + const cipher = factory(`aes-128-${mode}`, key, iv || Buffer.alloc(0)) + cipher.setAutoPadding(false) + return Buffer.concat([cipher.update(data), cipher.final()]) +} + +function nodePrepareKey(password: string): Buffer { + let pkey: Buffer = Buffer.from([ + 147, 196, 103, 227, 125, 176, 199, 164, 209, 190, 63, 129, 1, 82, 203, 86, + ]) + const pw = Buffer.from(password) + for (let round = 65536; round--; ) { + for (let j = 0; j < pw.length; j += 16) { + const key = Buffer.alloc(16) + for (let i = 0; i < 16; i += 4) { + if (i + j < pw.length) pw.copy(key, i, i + j, i + j + 4) + } + pkey = nodeAes("ecb", "encrypt", key, pkey) + } + } + return pkey +} + +function nodeStringHash(value: string, key: Buffer): Buffer { + const input = Buffer.from(value) + const words = [0, 0, 0, 0] + for (let i = 0; i < input.length; i += 4) { + if (input.length - i < 4) { + const rest = input.length - i + words[(i / 4) & 3] ^= input.readIntBE(i, rest) << ((4 - rest) * 8) + } else { + words[(i / 4) & 3] ^= input.readInt32BE(i) + } + } + let hash: Buffer = Buffer.alloc(16) + for (let i = 0; i < 4; i++) hash.writeInt32BE(words[i], i * 4) + const cipher = crypto.createCipheriv("aes-128-ecb", key, Buffer.alloc(0)) + for (let i = 16384; i--; ) hash = cipher.update(hash) + return hash +} + +test("AES-ECB / AES-CBC match the runtime implementation", () => { + const key = crypto.randomBytes(16) + const plain = crypto.randomBytes(64) + const iv = crypto.randomBytes(16) + + const ecb = aesEcbEncrypt(key, plain) + assert.deepEqual(Buffer.from(ecb), nodeAes("ecb", "encrypt", key, plain)) + assert.deepEqual(Buffer.from(aesEcbDecrypt(key, ecb)), plain) + + const cbc = aesCbcEncrypt(key, iv, plain) + assert.deepEqual(Buffer.from(cbc), nodeAes("cbc", "encrypt", key, plain, iv)) + assert.deepEqual(Buffer.from(aesCbcDecrypt(key, iv, cbc)), plain) +}) + +test("prepareKey implements the 65536 round MEGA derivation", () => { + for (const password of ["password123", "correct horse battery staple!"]) { + const expected = nodePrepareKey(password) + assert.deepEqual(Buffer.from(prepareKey(password)), expected) + } +}) + +test("stringhash produces the MEGA v1 user handle", () => { + const userKey = prepareKey("password123") + const expected = nodeStringHash("test@example.com", Buffer.from(userKey)) + const actual = stringhash("test@example.com", userKey) + assert.deepEqual(Buffer.from(actual), expected) + assert.equal(bytesToB64Url(actual), "jNU3r9rZg4C2B6ywq7xwEw") +}) + +test("prepareKeyV2 matches PBKDF2-SHA512 with the account salt", async () => { + const salt = crypto.randomBytes(16) + const expected = crypto.pbkdf2Sync("password123", salt, 100_000, 32, "sha512") + const actual = await prepareKeyV2("password123", new Uint8Array(salt)) + assert.deepEqual(Buffer.from(actual), expected) +}) + +test("node key and attributes round-trip", () => { + const masterKey = crypto.randomBytes(16) + const folderKey = crypto.randomBytes(16) + const storedKey = aesEcbEncrypt(masterKey, folderKey) + const decryptedKey = aesEcbDecrypt(masterKey, storedKey) + assert.deepEqual(Buffer.from(decryptedKey), folderKey) + + const attributes = Buffer.from('MEGA{"n":"测试目录"}') + const padded = Buffer.alloc(Math.ceil(attributes.length / 16) * 16) + attributes.copy(padded) + const encrypted = aesCbcEncrypt( + nodeCipherKey(decryptedKey), + new Uint8Array(16), + padded, + ) + const plain = aesCbcDecrypt( + nodeCipherKey(folderKey), + new Uint8Array(16), + encrypted, + ) + let end = 0 + while (end < plain.length && plain[end] !== 0) end++ + assert.equal( + new TextDecoder().decode(plain.subarray(0, end)), + 'MEGA{"n":"测试目录"}', + ) +}) + +function parseDer( + buffer: Buffer, + offset: number, +): { value: Buffer; next: number } { + let length = buffer[offset + 1] + let cursor = offset + 2 + if (length & 0x80) { + const count = length & 0x7f + length = 0 + for (let i = 0; i < count; i++) length = (length << 8) | buffer[cursor++] + } + return { + value: buffer.subarray(cursor, cursor + length), + next: cursor + length, + } +} + +function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { + let result = 1n + let b = base % modulus + let e = exponent + while (e > 0n) { + if (e & 1n) result = (result * b) % modulus + b = (b * b) % modulus + e >>= 1n + } + return result +} + +test("MEGA private key + csid decrypt to the session id", () => { + const { privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + }) + const der = privateKey.export({ type: "pkcs1", format: "der" }) + const sequence = parseDer(der, 0) + const integers: bigint[] = [] + let cursor = 0 + while (cursor < sequence.value.length) { + const item = parseDer(sequence.value, cursor) + integers.push(BigInt(`0x${item.value.toString("hex") || "0"}`)) + cursor = item.next + } + const [, n, e, d, p, q] = integers + assert.equal(p * q, n) + + const sidBytes = crypto.randomBytes(43) + const cipher = modPow(BigInt(`0x${sidBytes.toString("hex")}`), e, n) + const csid = encodeMpi(cipher) + + const privk = Buffer.concat([ + encodeMpi(p), + encodeMpi(q), + encodeMpi(d), + encodeMpi(0n), + ]) + const key = decodeMegaPrivateKey(new Uint8Array(privk)) + assert.equal(key.p, p) + assert.equal(key.q, q) + assert.equal(decryptSessionId(key, csid), sidBytes.toString("base64url")) + assert.equal(b64ToBytes(sidBytes.toString("base64url")).length, 43) + assert.equal(utf8ToBytes("fixture").length, 7) +}) diff --git a/src/backend/drivers/mega/crypto.ts b/src/backend/drivers/mega/crypto.ts new file mode 100644 index 00000000..b17e9171 --- /dev/null +++ b/src/backend/drivers/mega/crypto.ts @@ -0,0 +1,497 @@ +/** + * MEGA 客户端所需的最小密码学实现。 + * + * MEGA 全程使用 AES-128:账号密码派生、用户句柄、节点密钥与属性都是 + * AES-128-ECB / AES-128-CBC(无 padding)。Workers 的 WebCrypto 只提供 + * AES-CBC(ECB 需要用零 IV 按块模拟),而且 prepareKey 需要 65536 轮 + * 16 字节 ECB 加密,逐轮 await 的调度开销远大于计算本身,因此这里实现 + * 一个同步的 AES-128 分组加解密,供派生与解密使用;PBKDF2-SHA512(v2 + * 账号)仍然交给原生 WebCrypto。 + */ + +const SBOX = new Uint8Array([ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, + 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, + 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, + 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, + 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, + 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, + 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, + 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, + 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, + 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, + 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, + 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, + 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, + 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, + 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, + 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, + 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, + 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, + 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, +]) + +const INV_SBOX = new Uint8Array(256) +for (let i = 0; i < 256; i++) INV_SBOX[SBOX[i]] = i + +const RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36] +const BLOCK = 16 + +const xtime = (x: number): number => ((x << 1) ^ (x & 0x80 ? 0x1b : 0)) & 0xff + +function gmul(a: number, b: number): number { + let product = 0 + for (let i = 0; i < 8; i++) { + if (b & 1) product ^= a + const hi = a & 0x80 + a = (a << 1) & 0xff + if (hi) a ^= 0x1b + b >>= 1 + } + return product +} + +const MUL9 = new Uint8Array(256) +const MUL11 = new Uint8Array(256) +const MUL13 = new Uint8Array(256) +const MUL14 = new Uint8Array(256) +for (let i = 0; i < 256; i++) { + MUL9[i] = gmul(i, 9) + MUL11[i] = gmul(i, 11) + MUL13[i] = gmul(i, 13) + MUL14[i] = gmul(i, 14) +} + +/** AES-128 密钥扩展:16 字节密钥 → 11 组轮密钥(176 字节)。 */ +function expandKey(key: Uint8Array): Uint8Array { + if (key.length !== BLOCK) { + throw new Error("MEGA AES keys must be 128-bit") + } + const w = new Uint8Array(176) + w.set(key) + let rcon = 0 + for (let i = 16; i < 176; i += 4) { + let t0 = w[i - 4] + let t1 = w[i - 3] + let t2 = w[i - 2] + let t3 = w[i - 1] + if (i % 16 === 0) { + const tmp = t0 + t0 = SBOX[t1] ^ RCON[rcon++] + t1 = SBOX[t2] + t2 = SBOX[t3] + t3 = SBOX[tmp] + } + w[i] = w[i - 16] ^ t0 + w[i + 1] = w[i - 15] ^ t1 + w[i + 2] = w[i - 14] ^ t2 + w[i + 3] = w[i - 13] ^ t3 + } + return w +} + +function addRoundKey(s: Uint8Array, roundKeys: Uint8Array, offset: number) { + for (let i = 0; i < BLOCK; i++) s[i] ^= roundKeys[offset + i] +} + +function shiftRows(s: Uint8Array) { + let t = s[1] + s[1] = s[5] + s[5] = s[9] + s[9] = s[13] + s[13] = t + t = s[2] + s[2] = s[10] + s[10] = t + t = s[6] + s[6] = s[14] + s[14] = t + t = s[15] + s[15] = s[11] + s[11] = s[7] + s[7] = s[3] + s[3] = t +} + +function invShiftRows(s: Uint8Array) { + let t = s[13] + s[13] = s[9] + s[9] = s[5] + s[5] = s[1] + s[1] = t + t = s[2] + s[2] = s[10] + s[10] = t + t = s[6] + s[6] = s[14] + s[14] = t + t = s[3] + s[3] = s[7] + s[7] = s[11] + s[11] = s[15] + s[15] = t +} + +function mixColumns(s: Uint8Array) { + for (let c = 0; c < BLOCK; c += 4) { + const a0 = s[c] + const a1 = s[c + 1] + const a2 = s[c + 2] + const a3 = s[c + 3] + s[c] = xtime(a0) ^ (xtime(a1) ^ a1) ^ a2 ^ a3 + s[c + 1] = a0 ^ xtime(a1) ^ (xtime(a2) ^ a2) ^ a3 + s[c + 2] = a0 ^ a1 ^ xtime(a2) ^ (xtime(a3) ^ a3) + s[c + 3] = xtime(a0) ^ a0 ^ a1 ^ a2 ^ xtime(a3) + } +} + +function invMixColumns(s: Uint8Array) { + for (let c = 0; c < BLOCK; c += 4) { + const a0 = s[c] + const a1 = s[c + 1] + const a2 = s[c + 2] + const a3 = s[c + 3] + s[c] = MUL14[a0] ^ MUL11[a1] ^ MUL13[a2] ^ MUL9[a3] + s[c + 1] = MUL9[a0] ^ MUL14[a1] ^ MUL11[a2] ^ MUL13[a3] + s[c + 2] = MUL13[a0] ^ MUL9[a1] ^ MUL14[a2] ^ MUL11[a3] + s[c + 3] = MUL11[a0] ^ MUL13[a1] ^ MUL9[a2] ^ MUL14[a3] + } +} + +function encryptBlock( + roundKeys: Uint8Array, + input: Uint8Array, + inOffset: number, + output: Uint8Array, + outOffset: number, +) { + const s = new Uint8Array(BLOCK) + for (let i = 0; i < BLOCK; i++) s[i] = input[inOffset + i] + addRoundKey(s, roundKeys, 0) + for (let round = 1; round < 10; round++) { + for (let i = 0; i < BLOCK; i++) s[i] = SBOX[s[i]] + shiftRows(s) + mixColumns(s) + addRoundKey(s, roundKeys, round * BLOCK) + } + for (let i = 0; i < BLOCK; i++) s[i] = SBOX[s[i]] + shiftRows(s) + addRoundKey(s, roundKeys, 160) + for (let i = 0; i < BLOCK; i++) output[outOffset + i] = s[i] +} + +function decryptBlock( + roundKeys: Uint8Array, + input: Uint8Array, + inOffset: number, + output: Uint8Array, + outOffset: number, +) { + const s = new Uint8Array(BLOCK) + for (let i = 0; i < BLOCK; i++) s[i] = input[inOffset + i] + addRoundKey(s, roundKeys, 160) + for (let round = 9; round > 0; round--) { + invShiftRows(s) + for (let i = 0; i < BLOCK; i++) s[i] = INV_SBOX[s[i]] + addRoundKey(s, roundKeys, round * BLOCK) + invMixColumns(s) + } + invShiftRows(s) + for (let i = 0; i < BLOCK; i++) s[i] = INV_SBOX[s[i]] + addRoundKey(s, roundKeys, 0) + for (let i = 0; i < BLOCK; i++) output[outOffset + i] = s[i] +} + +export function aesEcbEncrypt(key: Uint8Array, data: Uint8Array): Uint8Array { + if (data.length % BLOCK !== 0) { + throw new Error("MEGA AES-ECB input must be block aligned") + } + const roundKeys = expandKey(key) + const out = new Uint8Array(data.length) + for (let i = 0; i < data.length; i += BLOCK) { + encryptBlock(roundKeys, data, i, out, i) + } + return out +} + +export function aesEcbDecrypt(key: Uint8Array, data: Uint8Array): Uint8Array { + if (data.length % BLOCK !== 0) { + throw new Error("MEGA AES-ECB input must be block aligned") + } + const roundKeys = expandKey(key) + const out = new Uint8Array(data.length) + for (let i = 0; i < data.length; i += BLOCK) { + decryptBlock(roundKeys, data, i, out, i) + } + return out +} + +export function aesCbcDecrypt( + key: Uint8Array, + iv: Uint8Array, + data: Uint8Array, +): Uint8Array { + if (iv.length !== BLOCK) throw new Error("MEGA AES-CBC IV must be 16 bytes") + if (data.length % BLOCK !== 0) { + throw new Error("MEGA AES-CBC input must be block aligned") + } + const roundKeys = expandKey(key) + const out = new Uint8Array(data.length) + const previous = new Uint8Array(iv) + const block = new Uint8Array(BLOCK) + for (let i = 0; i < data.length; i += BLOCK) { + decryptBlock(roundKeys, data, i, block, 0) + for (let j = 0; j < BLOCK; j++) out[i + j] = block[j] ^ previous[j] + previous.set(data.subarray(i, i + BLOCK)) + } + return out +} + +export function aesCbcEncrypt( + key: Uint8Array, + iv: Uint8Array, + data: Uint8Array, +): Uint8Array { + if (iv.length !== BLOCK) throw new Error("MEGA AES-CBC IV must be 16 bytes") + if (data.length % BLOCK !== 0) { + throw new Error("MEGA AES-CBC input must be block aligned") + } + const roundKeys = expandKey(key) + const out = new Uint8Array(data.length) + const previous = new Uint8Array(iv) + const block = new Uint8Array(BLOCK) + for (let i = 0; i < data.length; i += BLOCK) { + for (let j = 0; j < BLOCK; j++) block[j] = data[i + j] ^ previous[j] + encryptBlock(roundKeys, block, 0, out, i) + previous.set(out.subarray(i, i + BLOCK)) + } + return out +} + +export function utf8ToBytes(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +export function bytesToB64Url(bytes: Uint8Array): string { + let binary = "" + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +export function b64ToBytes(text: string): Uint8Array { + let b64 = text.replace(/-/g, "+").replace(/_/g, "/") + while (b64.length % 4 !== 0) b64 += "=" + const binary = atob(b64) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} + +/** MEGA 账号主密钥常量,prepareKey 以此作为 65536 轮 ECB 的初始状态。 */ +const PW_KEY = new Uint8Array([ + 0x93, 0xc4, 0x67, 0xe3, 0x7d, 0xb0, 0xc7, 0xa4, 0xd1, 0xbe, 0x3f, 0x81, 0x01, + 0x52, 0xcb, 0x56, +]) + +/** + * v1 账号的用户密钥:把密码按 16 字节切块作为 AES 密钥,对固定常量做 + * 65536 轮 AES-128-ECB 加密。这就是 MEGA 客户端 prepare_key 的完整实现, + * 不能用「返回常量」的简化版代替,否则派生的 uh 永远匹配不上账号。 + * + * 性能:65536 轮都落在同一把密钥上,因此只做一次密钥扩展并原地加密, + * 避免每轮重复 expandKey。本地 V8 实测约 45ms。WebCrypto 的 65536 次 + * subtle.encrypt 反而要 ~1.1s(逐次 Promise 与参数校验的开销远大于 + * 16 字节 AES 本身),所以这里保持同步纯 JS 实现。 + */ +export function prepareKey(password: string): Uint8Array { + const pw = utf8ToBytes(password) + const rounds: Uint8Array[] = [] + for (let j = 0; j < pw.length; j += BLOCK) { + const key = new Uint8Array(BLOCK) + key.set(pw.subarray(j, Math.min(j + BLOCK, pw.length))) + rounds.push(expandKey(key)) + } + + const pkey = PW_KEY.slice() + for (let round = 0; round < 65536; round++) { + for (const roundKeys of rounds) { + encryptBlock(roundKeys, pkey, 0, pkey, 0) + } + } + return pkey +} + +/** + * MEGA 的 stringhash:把字符串折叠成 4 个 32 位大端字,再用用户密钥做 + * 16384 轮 AES-128-ECB,结果 16 字节就是 v1 账号的 uh(base64url 后)。 + */ +export function stringhash(value: string, key: Uint8Array): Uint8Array { + const input = utf8ToBytes(value) + const words = [0, 0, 0, 0] + for (let i = 0; i < input.length; i += 4) { + if (input.length - i < 4) { + const rest = input.length - i + let partial = 0 + for (let j = 0; j < rest; j++) partial = (partial << 8) | input[i + j] + words[(i >> 2) & 3] ^= partial << ((4 - rest) * 8) + } else { + words[(i >> 2) & 3] ^= + (input[i] << 24) | + (input[i + 1] << 16) | + (input[i + 2] << 8) | + input[i + 3] + } + } + + let hash: Uint8Array = new Uint8Array(BLOCK) + for (let i = 0; i < 4; i++) { + hash[i * 4] = (words[i] >>> 24) & 0xff + hash[i * 4 + 1] = (words[i] >>> 16) & 0xff + hash[i * 4 + 2] = (words[i] >>> 8) & 0xff + hash[i * 4 + 3] = words[i] & 0xff + } + for (let i = 0; i < 16384; i++) hash = aesEcbEncrypt(key, hash) + return hash +} + +/** v2 账号:PBKDF2-SHA512(password, salt, 100000) 派生 32 字节。 */ +export async function prepareKeyV2( + password: string, + salt: Uint8Array, +): Promise { + const subtle = globalThis.crypto?.subtle + if (!subtle) throw new Error("WebCrypto is unavailable for MEGA login") + const material = await subtle.importKey( + "raw", + utf8ToBytes(password) as BufferSource, + "PBKDF2", + false, + ["deriveBits"], + ) + const bits = await subtle.deriveBits( + { + name: "PBKDF2", + salt: salt as BufferSource, + iterations: 100_000, + hash: "SHA-512", + }, + material, + 256, + ) + return new Uint8Array(bits) +} + +/** + * 节点属性解密用的 AES 密钥:MEGA 的节点密钥是 32 字节「密钥 + MAC」, + * 前 16 字节与后 16 字节异或后才是属性/文件内容真正使用的密钥。 + */ +export function nodeCipherKey(nodeKey: Uint8Array): Uint8Array { + if (nodeKey.length === BLOCK) return nodeKey.slice() + const key = new Uint8Array(BLOCK) + for (let i = 0; i < BLOCK; i++) key[i] = nodeKey[i] ^ nodeKey[i + BLOCK] + return key +} + +function bitLength(value: bigint): number { + let bits = 0 + let v = value + while (v > 0n) { + v >>= 1n + bits++ + } + return bits +} + +function bigIntToBytes(value: bigint): Uint8Array { + if (value === 0n) return new Uint8Array(0) + const length = Math.ceil(bitLength(value) / 8) + const out = new Uint8Array(length) + let v = value + for (let i = length - 1; i >= 0; i--) { + out[i] = Number(v & 0xffn) + v >>= 8n + } + return out +} + +/** MEGA 的 MPI 编码:2 字节大端位长 + 大端有效字节。 */ +export function encodeMpi(value: bigint): Uint8Array { + const bytes = bigIntToBytes(value) + const out = new Uint8Array(2 + bytes.length) + const bits = bitLength(value) + out[0] = (bits >>> 8) & 0xff + out[1] = bits & 0xff + out.set(bytes, 2) + return out +} + +function readMpi( + data: Uint8Array, + offset: number, +): { value: bigint; next: number } { + if (offset + 2 > data.length) { + throw new Error("MEGA RSA key is truncated") + } + const bits = (data[offset] << 8) | data[offset + 1] + const length = ((bits + 7) >> 3) + 2 + if (offset + length > data.length) { + throw new Error("MEGA RSA key is truncated") + } + let value = 0n + for (let i = offset + 2; i < offset + length; i++) { + value = (value << 8n) | BigInt(data[i]) + } + return { value, next: offset + length } +} + +export interface MegaRsaKey { + p: bigint + q: bigint + d: bigint + /** p⁻¹ mod q,MEGA 的 privk 第 4 个 MPI。 */ + u: bigint +} + +/** 解析解密后的 MEGA privk(p, q, d, u 四个 MPI)。 */ +export function decodeMegaPrivateKey(privk: Uint8Array): MegaRsaKey { + const p = readMpi(privk, 0) + const q = readMpi(privk, p.next) + const d = readMpi(privk, q.next) + const u = readMpi(privk, d.next) + if (p.value <= 0n || q.value <= 0n || d.value <= 0n) { + throw new Error("MEGA RSA private key is invalid") + } + return { p: p.value, q: q.value, d: d.value, u: u.value } +} + +function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { + let result = 1n + let b = base % modulus + let e = exponent + while (e > 0n) { + if (e & 1n) result = (result * b) % modulus + b = (b * b) % modulus + e >>= 1n + } + return result +} + +/** + * 用 RSA 私钥解密 csid 并取出 sid。csid 是 MPI 编码的密文;明文是 + * 43 字节(其余为 RSA 填充),base64url 后即为会话 sid。 + */ +export function decryptSessionId(key: MegaRsaKey, csid: Uint8Array): string { + const cipher = readMpi(csid, 0).value + const modulus = key.p * key.q + const plain = bigIntToBytes(modPow(cipher, key.d, modulus)) + const sid = new Uint8Array(43) + if (plain.length >= sid.length) { + sid.set(plain.subarray(0, sid.length)) + } else { + sid.set(plain, sid.length - plain.length) + } + return bytesToB64Url(sid) +} diff --git a/src/backend/drivers/mega/driver.test.ts b/src/backend/drivers/mega/driver.test.ts index 73e888e8..d3f012af 100644 --- a/src/backend/drivers/mega/driver.test.ts +++ b/src/backend/drivers/mega/driver.test.ts @@ -58,3 +58,160 @@ test("MegaDriver instantiation and methods", async () => { "https://gfs270n123.userstorage.mega.co.nz/download/video.mp4", ) }) + +import { bytesToB64Url } from "./crypto" +import { MegaApiError } from "./util" + +const MASTER_KEY = bytesToB64Url(new Uint8Array(16).fill(7)) + +test("MegaDriver reuses a persisted session instead of logging in", async () => { + const addition: MegaAddition = { + email: "test@example.com", + password: "password123", + sid: "cached-sid", + master_key: MASTER_KEY, + } + let persisted = 0 + const driver = new MegaDriver(addition, () => { + persisted++ + }) + const calls: string[] = [] + ;(driver as any).client = { + restoreSession: (sid: string, key: string) => + calls.push(`restore:${sid}:${key}`), + fetchNodes: async () => { + calls.push("fetch") + }, + init: async () => { + calls.push("init") + }, + getSession: () => null, + clearSession: () => calls.push("clear"), + } + + await driver.init() + assert.deepEqual(calls, [`restore:cached-sid:${MASTER_KEY}`, "fetch"]) + assert.equal(persisted, 0) +}) + +test("MegaDriver logs in again when the persisted session expired", async () => { + const addition: MegaAddition = { + email: "test@example.com", + password: "password123", + sid: "stale-sid", + master_key: MASTER_KEY, + } + const persisted: Array<{ sid: string; masterKey: string }> = [] + const driver = new MegaDriver(addition, async (session) => { + persisted.push(session) + }) + let cleared = false + let login = 0 + ;(driver as any).client = { + restoreSession: () => {}, + fetchNodes: async () => { + throw new MegaApiError(-15) + }, + clearSession: () => { + cleared = true + }, + init: async () => { + login++ + }, + getSession: () => ({ sid: "fresh-sid", masterKey: MASTER_KEY }), + } + + await driver.init() + assert.equal(cleared, true) + assert.equal(login, 1) + assert.deepEqual(persisted, [{ sid: "fresh-sid", masterKey: MASTER_KEY }]) + assert.equal(addition.sid, "fresh-sid") + assert.equal(addition.master_key, MASTER_KEY) +}) + +test("MegaDriver logs in and persists the session when nothing is cached", async () => { + const addition: MegaAddition = { + email: "test@example.com", + password: "password123", + } + const persisted: Array<{ sid: string; masterKey: string }> = [] + const driver = new MegaDriver(addition, (session) => { + persisted.push(session) + }) + let login = 0 + ;(driver as any).client = { + init: async () => { + login++ + }, + getSession: () => ({ sid: "new-sid", masterKey: MASTER_KEY }), + } + + await driver.init() + assert.equal(login, 1) + assert.deepEqual(persisted, [{ sid: "new-sid", masterKey: MASTER_KEY }]) + assert.equal(addition.sid, "new-sid") +}) + +test("MegaDriver propagates non-session errors and survives persistence failures", async () => { + const addition: MegaAddition = { + email: "test@example.com", + password: "password123", + sid: "sid", + master_key: MASTER_KEY, + } + const driver = new MegaDriver(addition) + ;(driver as any).client = { + restoreSession: () => {}, + fetchNodes: async () => { + throw new MegaApiError(-11) + }, + clearSession: () => {}, + init: async () => {}, + getSession: () => null, + } + await assert.rejects(() => driver.init(), /Mega API error code: -11/) + + const clean: MegaAddition = { + email: "test@example.com", + password: "password123", + } + const failing = new MegaDriver(clean, async () => { + throw new Error("db down") + }) + ;(failing as any).client = { + init: async () => {}, + getSession: () => ({ sid: "sid2", masterKey: MASTER_KEY }), + } + await failing.init() + assert.equal(clean.sid, "sid2") +}) + +test("MegaDriver falls back to login when the persisted session is malformed", async () => { + const addition: MegaAddition = { + email: "test@example.com", + password: "password123", + sid: "sid", + master_key: "not-a-valid-key", + } + let login = 0 + let cleared = false + const driver = new MegaDriver(addition, () => {}) + ;(driver as any).client = { + restoreSession: () => { + throw new Error("bad master key") + }, + fetchNodes: async () => {}, + clearSession: () => { + cleared = true + }, + init: async () => { + login++ + }, + getSession: () => ({ sid: "fresh-sid", masterKey: MASTER_KEY }), + } + + await driver.init() + assert.equal(cleared, true) + assert.equal(login, 1) + assert.equal(addition.sid, "fresh-sid") +}) diff --git a/src/backend/drivers/mega/driver.ts b/src/backend/drivers/mega/driver.ts index 3e232450..2bb8b6ab 100644 --- a/src/backend/drivers/mega/driver.ts +++ b/src/backend/drivers/mega/driver.ts @@ -5,19 +5,67 @@ import { } from "../../internal/driver/base" import { sortFileItems } from "../../internal/driver/sort" import { MegaAddition, MegaNodeItem } from "./types" -import { MegaApiClient } from "./util" +import { MegaApiClient, MegaApiError } from "./util" + +export interface MegaSession { + sid: string + masterKey: string +} + +export type PersistMegaSession = (session: MegaSession) => Promise | void export class MegaDriver implements StorageDriver { private addition: MegaAddition private client: MegaApiClient + private persistSession?: PersistMegaSession - constructor(addition: MegaAddition) { + constructor(addition: MegaAddition, persistSession?: PersistMegaSession) { this.addition = addition this.client = new MegaApiClient(addition) + this.persistSession = persistSession } async init(): Promise { + // 复用上次持久化的会话;sid/master key 任缺其一都只能重新登录(缺 + // master key 时虽然能列目录,但节点名和下载密钥都解不开)。 + const cachedSid = this.addition.sid?.trim() + const cachedKey = this.addition.master_key?.trim() + let resumed = false + if (cachedSid && cachedKey) { + try { + this.client.restoreSession(cachedSid, cachedKey) + resumed = true + } catch (e) { + // 存储里的会话被截断/格式不对时按未登录处理,不能因此让挂载失败。 + console.warn("[Mega] invalid persisted session, logging in again:", e) + this.client.clearSession() + } + } + + if (resumed) { + try { + await this.client.fetchNodes() + return + } catch (e) { + if (!(e instanceof MegaApiError) || e.code !== -15) throw e + console.warn("[Mega] persisted session expired, logging in again") + this.client.clearSession() + } + } + await this.client.init() + const session = this.client.getSession() + if (!session) return + + this.addition.sid = session.sid + this.addition.master_key = session.masterKey + if (!this.persistSession) return + try { + await this.persistSession(session) + } catch (e) { + // 持久化失败只影响下次冷启动要重新登录,不能因此让本次挂载失败。 + console.warn("[Mega] failed to persist session:", e) + } } private cleanPath(p: string): string { diff --git a/src/backend/drivers/mega/types.ts b/src/backend/drivers/mega/types.ts index 197cd0e4..80595d2e 100644 --- a/src/backend/drivers/mega/types.ts +++ b/src/backend/drivers/mega/types.ts @@ -7,6 +7,10 @@ export interface MegaAddition { root_folder_id?: string order_by?: string order_direction?: string + /** 驱动回写的会话,避免每个 isolate 冷启动都重新登录并做一次 Hashcash */ + sid?: string + /** 与 sid 配套的账号 master key(base64url,16 字节),用于解密节点密钥 */ + master_key?: string } export interface MegaNodeItem { @@ -19,6 +23,8 @@ export interface MegaNodeItem { type: number // 0=file, 1=folder, 2=root, 3=inbox, 4=trash raw_url?: string key?: string + /** 解密后的节点密钥(16/32 字节,base64url),下载客户端解密时使用 */ + file_key?: string } export interface MegaQuotaResp { diff --git a/src/backend/drivers/mega/util.test.ts b/src/backend/drivers/mega/util.test.ts new file mode 100644 index 00000000..72599b9d --- /dev/null +++ b/src/backend/drivers/mega/util.test.ts @@ -0,0 +1,672 @@ +import assert from "node:assert/strict" +import crypto from "node:crypto" +import test from "node:test" +import { + aesCbcEncrypt, + aesEcbEncrypt, + bytesToB64Url, + encodeMpi, + nodeCipherKey, + prepareKey, + stringhash, +} from "./crypto" +import { MegaApiClient, generateHashcashToken } from "./util" +import type { MegaAddition } from "./types" + +const TOKEN = "A".repeat(64) // 48 zero bytes encoded as unpadded base64url + +function digestWord(word: number): ArrayBuffer { + const bytes = new Uint8Array(32) + bytes[0] = (word >>> 24) & 0xff + bytes[1] = (word >>> 16) & 0xff + bytes[2] = (word >>> 8) & 0xff + bytes[3] = word & 0xff + return bytes.buffer +} + +test("MEGA Hashcash matches the challenge format and increments the nonce", async () => { + const prefixes: number[][] = [] + let calls = 0 + const response = await generateHashcashToken(`1:255:ignored:${TOKEN}`, { + digest: async (input) => { + calls++ + prefixes.push(Array.from(input.slice(0, 4))) + // At easiness=255 the threshold is 0x7f000000. Force one miss then pass. + return digestWord(calls === 1 ? 0xffffffff : 0) + }, + maxAttempts: 2, + maxDurationMs: 1000, + now: () => 0, + }) + + assert.equal(response, `1:${TOKEN}:AgAAAA`) + assert.deepEqual(prefixes, [ + [1, 0, 0, 0], + [2, 0, 0, 0], + ]) +}) + +test("MEGA Hashcash rejects malformed or unsupported challenges", async () => { + await assert.rejects( + () => generateHashcashToken(`2:255:ignored:${TOKEN}`), + /unsupported Hashcash challenge/, + ) + await assert.rejects( + () => generateHashcashToken(`1:256:ignored:${TOKEN}`), + /unsupported Hashcash challenge/, + ) + await assert.rejects( + () => generateHashcashToken(`1:255:ignored:${"A".repeat(63)}`), + /must decode to 48 bytes/, + ) +}) + +test("MEGA Hashcash solver stops at its configured work budget", async () => { + await assert.rejects( + () => + generateHashcashToken(`1:255:ignored:${TOKEN}`, { + digest: async () => digestWord(0xffffffff), + maxAttempts: 2, + maxDurationMs: 1000, + now: () => 0, + }), + /exceeded the serverless work budget/, + ) +}) + +test("MegaApiClient retries a Hashcash challenge with the official request header", async () => { + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; init?: RequestInit }> = [] + let solverCalls = 0 + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + async (challenge) => { + solverCalls++ + assert.equal(challenge, `1:255:ignored:${TOKEN}`) + return `1:${TOKEN}:AgAAAA` + }, + ) + + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + calls.push({ url: String(input), init }) + if (calls.length === 1) { + return new Response("", { + status: 402, + headers: { "X-Hashcash": `1:255:ignored:${TOKEN}` }, + }) + } + return new Response(JSON.stringify([{ sid: "session" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch + + try { + const body = [{ a: "us", user: "user@example.com", uh: "user-hash" }] + const result = await client.request(body) + assert.deepEqual(result, [{ sid: "session" }]) + assert.equal(solverCalls, 1) + assert.equal(calls.length, 2) + assert.equal(calls[0].url, calls[1].url) + assert.deepEqual(JSON.parse(String(calls[0].init?.body)), body) + assert.deepEqual(JSON.parse(String(calls[1].init?.body)), body) + assert.equal( + (calls[0].init?.headers as Record)?.["X-Hashcash"], + undefined, + ) + assert.equal( + (calls[1].init?.headers as Record)?.["X-Hashcash"], + `1:${TOKEN}:AgAAAA`, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient handles Hashcash challenges on non-login API actions too", async () => { + const originalFetch = globalThis.fetch + let calls = 0 + let solverCalls = 0 + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + async (challenge) => { + solverCalls++ + assert.equal(challenge, `1:255:ignored:${TOKEN}`) + return `1:${TOKEN}:AgAAAA` + }, + ) + globalThis.fetch = (async () => { + calls++ + return calls === 1 + ? new Response("", { + status: 402, + headers: { "X-Hashcash": `1:255:ignored:${TOKEN}` }, + }) + : new Response("[0]", { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch + + try { + assert.deepEqual(await client.request([{ a: "f" }]), [0]) + assert.equal(calls, 2) + assert.equal(solverCalls, 1) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient keeps ordinary 402 responses without Hashcash as API errors", async () => { + const originalFetch = globalThis.fetch + let solverCalls = 0 + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + async () => { + solverCalls++ + return "unexpected" + }, + ) + globalThis.fetch = (async () => + new Response("payment required", { status: 402 })) as typeof fetch + + try { + await assert.rejects( + () => client.request([{ a: "f" }]), + /Mega API HTTP error: 402/, + ) + assert.equal(solverCalls, 0) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient bounds repeated login Hashcash challenges", async () => { + const originalFetch = globalThis.fetch + let calls = 0 + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + async () => `1:${TOKEN}:AgAAAA`, + ) + globalThis.fetch = (async () => { + calls++ + return new Response("", { + status: 402, + headers: { "X-Hashcash": `1:255:ignored:${TOKEN}` }, + }) + }) as typeof fetch + + try { + await assert.rejects( + () => client.request([{ a: "us" }]), + /repeated the Hashcash challenge after retry/, + ) + assert.equal(calls, 2) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MEGA Hashcash accepts base64url tokens containing - and _", async () => { + // MEGA 下发的 token 用的是 base64url 变体;解码正则同时放行 +/ 与 -_, + // 这里锁住 -_ 分支,避免将来只按标准 base64 处理。 + const token = Buffer.from(new Array(48).fill(0xfb)).toString("base64url") + assert.match(token, /[-_]/) + + const response = await generateHashcashToken(`1:255:ignored:${token}`, { + digest: async () => digestWord(0), + maxAttempts: 1, + maxDurationMs: 1000, + now: () => 0, + }) + assert.equal(response, `1:${token}:AQAAAA`) +}) + +test("MEGA Hashcash default budget scales with the challenge easiness", async () => { + // 不传 maxAttempts 时应按 2^32/(threshold+1)*4 收敛: + // easiness=255 → threshold=0x7f000000 → 期望 3 次 → 预算 12 次 + // (成功率 1-(1-1/3)^12 ≈ 98.8%)。固定 4096 次的旧实现会白跑 4084 次。 + let calls = 0 + await assert.rejects( + () => + generateHashcashToken(`1:255:ignored:${TOKEN}`, { + digest: async () => { + calls++ + return digestWord(0xffffffff) + }, + maxDurationMs: 60_000, + now: () => 0, + }), + /exceeded the serverless work budget/, + ) + assert.equal(calls, 12) +}) + +test("MegaApiClient streams only the error prefix instead of buffering the body", async () => { + const originalFetch = globalThis.fetch + const client = new MegaApiClient({ + email: "user@example.com", + password: "password", + }) + let pulled = 0 + const body = new ReadableStream({ + pull(controller) { + pulled++ + controller.enqueue(new Uint8Array(65_536).fill(0x78)) + if (pulled >= 200) controller.close() + }, + }) + globalThis.fetch = (async () => + new Response(body, { status: 400 })) as typeof fetch + + try { + await assert.rejects( + () => client.request([{ a: "f" }]), + (err: any) => { + const message = String(err?.message || "") + assert.match(message, /Mega API HTTP error: 400/) + assert.ok( + message.length < 600, + `error message too long: ${message.length}`, + ) + return true + }, + ) + // 512 字节上限只需一两个 64 KiB chunk;res.text() 会拉完整个 ~12 MiB。 + assert.ok(pulled <= 2, `read too many chunks: ${pulled}`) + } finally { + globalThis.fetch = originalFetch + } +}) + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }) +} + +function pad16(bytes: Uint8Array): Uint8Array { + const padded = new Uint8Array(Math.max(16, Math.ceil(bytes.length / 16) * 16)) + padded.set(bytes) + return padded +} + +function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { + let result = 1n + let b = base % modulus + let e = exponent + while (e > 0n) { + if (e & 1n) result = (result * b) % modulus + b = (b * b) % modulus + e >>= 1n + } + return result +} + +function readDer( + buffer: Uint8Array, + offset: number, +): { value: Uint8Array; next: number } { + let length = buffer[offset + 1] + let cursor = offset + 2 + if (length & 0x80) { + const count = length & 0x7f + length = 0 + for (let i = 0; i < count; i++) length = (length << 8) | buffer[cursor++] + } + return { + value: buffer.subarray(cursor, cursor + length), + next: cursor + length, + } +} + +/** 用 node:crypto 生成一套与真实响应同构的登录材料(k/privk/csid/sid)。 */ +function buildLoginFixture(userKey: Uint8Array) { + const { privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + }) + const der = privateKey.export({ type: "pkcs1", format: "der" }) + const sequence = readDer(der, 0) + const integers: bigint[] = [] + let cursor = 0 + while (cursor < sequence.value.length) { + const item = readDer(sequence.value, cursor) + integers.push(BigInt(`0x${Buffer.from(item.value).toString("hex") || "0"}`)) + cursor = item.next + } + const [, n, e, d, p, q] = integers + + const masterKey = crypto.randomBytes(16) + const sidBytes = crypto.randomBytes(43) + const cipher = modPow(BigInt(`0x${sidBytes.toString("hex")}`), e, n) + const privkPlain = Buffer.concat([ + encodeMpi(p), + encodeMpi(q), + encodeMpi(d), + encodeMpi(0n), + ]) + + return { + masterKey, + sid: sidBytes.toString("base64url"), + response: { + k: bytesToB64Url(aesEcbEncrypt(userKey, masterKey)), + privk: bytesToB64Url(aesEcbEncrypt(masterKey, pad16(privkPlain))), + csid: bytesToB64Url(encodeMpi(cipher)), + }, + } +} + +function buildFolderNode( + masterKey: Uint8Array, + handle: string, + parent: string, + name: string, +) { + const folderKey = crypto.randomBytes(16) + const plain = pad16( + new TextEncoder().encode(`MEGA${JSON.stringify({ n: name })}`), + ) + const encryptedAttrs = aesCbcEncrypt( + nodeCipherKey(folderKey), + new Uint8Array(16), + plain, + ) + return { + folderKey, + node: { + h: handle, + p: parent, + t: 1, + ts: 1_700_000_000, + k: bytesToB64Url(aesEcbEncrypt(masterKey, folderKey)), + a: bytesToB64Url(encryptedAttrs), + }, + } +} + +test("MegaApiClient logs in with the v1 password key and decrypts nodes", async () => { + const originalFetch = globalThis.fetch + const email = "User@Example.com " + const password = "password123" + const userKey = prepareKey(password) + const fixture = buildLoginFixture(userKey) + const folder = buildFolderNode(fixture.masterKey, "folder", "root", "文档") + const calls: Array<{ url: string; body: any }> = [] + + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const body = JSON.parse(String(init?.body))[0] + calls.push({ url: String(input), body }) + if (body.a === "us0") return jsonResponse([{ v: 1 }]) + if (body.a === "us") return jsonResponse([fixture.response]) + if (body.a === "f") { + return jsonResponse([ + { + f: [{ h: "root", p: "root", t: 2, ts: 1 }, folder.node], + }, + ]) + } + throw new Error(`unexpected action ${body.a}`) + }) as typeof fetch + + try { + const client = new MegaApiClient({ email, password }) + await client.init() + + assert.deepEqual( + calls.map((call) => call.body.a), + ["us0", "us", "f"], + ) + assert.equal(calls[1].body.user, "user@example.com") + assert.equal( + calls[1].body.uh, + bytesToB64Url(stringhash("user@example.com", userKey)), + ) + assert.equal(calls[1].body.uh, "9QPYw6nl-n2fsLSTCyxrqg") + assert.match(calls[2].url, new RegExp(`&sid=${fixture.sid}$`)) + + assert.equal(client.getRootId(), "root") + const children = client.getChildren("root") + assert.equal(children.length, 1) + assert.equal(children[0].name, "文档") + assert.equal(children[0].file_key, bytesToB64Url(folder.folderKey)) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient supports v2 accounts and forwards the 2FA code", async () => { + const originalFetch = globalThis.fetch + const password = "password123" + const salt = crypto.randomBytes(16) + const derived = crypto.pbkdf2Sync(password, salt, 100_000, 32, "sha512") + const userKey = new Uint8Array(derived.subarray(0, 16)) + const fixture = buildLoginFixture(userKey) + const calls: any[] = [] + + globalThis.fetch = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + const body = JSON.parse(String(init?.body))[0] + calls.push(body) + if (body.a === "us0") { + return jsonResponse([{ v: 2, s: salt.toString("base64url") }]) + } + if (body.a === "us") return jsonResponse([fixture.response]) + if (body.a === "f") return jsonResponse([{ f: [] }]) + throw new Error(`unexpected action ${body.a}`) + }) as typeof fetch + + try { + const client = new MegaApiClient({ + email: "user@example.com", + password, + two_fa_code: " 123456 ", + } satisfies MegaAddition) + await client.init() + + assert.equal(calls[1].uh, bytesToB64Url(derived.subarray(16, 32))) + assert.equal(calls[1].mfa, "123456") + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient reports MEGA -9 as a credential error", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + const body = JSON.parse(String(init?.body))[0] + if (body.a === "us0") return jsonResponse([{ v: 1 }]) + return jsonResponse([-9]) + }) as typeof fetch + + try { + const client = new MegaApiClient({ + email: "user@example.com", + password: "wrong-password", + }) + await assert.rejects( + () => client.init(), + /Mega authentication failed: -9 \(wrong email or password\)/, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient retries 503 Server Too Busy with backoff", async () => { + const originalFetch = globalThis.fetch + const sleeps: number[] = [] + let calls = 0 + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + undefined, + async (ms) => { + sleeps.push(ms) + }, + ) + globalThis.fetch = (async () => { + calls++ + return calls === 1 + ? new Response("", { + status: 503, + statusText: "Server Too Busy", + }) + : jsonResponse([0]) + }) as typeof fetch + + try { + assert.deepEqual(await client.request([{ a: "f" }]), [0]) + assert.equal(calls, 2) + assert.equal(sleeps.length, 1) + // 400ms 基数 + 最多 250ms 抖动 + assert.ok(sleeps[0] >= 400 && sleeps[0] <= 650, `delay=${sleeps[0]}`) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient bounds transient 5xx retries", async () => { + const originalFetch = globalThis.fetch + let calls = 0 + let sleeps = 0 + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + undefined, + async () => { + sleeps++ + }, + ) + globalThis.fetch = (async () => { + calls++ + return new Response("", { status: 503, statusText: "Server Too Busy" }) + }) as typeof fetch + + try { + await assert.rejects( + () => client.request([{ a: "f" }]), + /Mega API HTTP error: 503 Server Too Busy/, + ) + assert.equal(calls, 5) + assert.equal(sleeps, 4) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient retries MEGA -3/-4 and reports them after the budget", async () => { + const originalFetch = globalThis.fetch + let calls = 0 + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + undefined, + async () => {}, + ) + + try { + globalThis.fetch = (async () => { + calls++ + return jsonResponse(calls === 1 ? [-3] : [0]) + }) as typeof fetch + assert.deepEqual(await client.request([{ a: "f" }]), [0]) + assert.equal(calls, 2) + + calls = 0 + globalThis.fetch = (async () => { + calls++ + return jsonResponse([-4]) + }) as typeof fetch + await assert.rejects( + () => client.request([{ a: "f" }]), + /Mega API error code: -4/, + ) + assert.equal(calls, 5) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient keeps the solved Hashcash header across a 503 retry", async () => { + const originalFetch = globalThis.fetch + const calls: Array = [] + const client = new MegaApiClient( + { email: "user@example.com", password: "password" }, + async () => `1:${TOKEN}:AgAAAA`, + async () => {}, + ) + globalThis.fetch = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + calls.push(init) + if (calls.length === 1) { + return new Response("", { + status: 402, + headers: { "X-Hashcash": `1:255:ignored:${TOKEN}` }, + }) + } + if (calls.length === 2) { + return new Response("", { status: 503 }) + } + return jsonResponse([0]) + }) as typeof fetch + + try { + assert.deepEqual(await client.request([{ a: "f" }]), [0]) + assert.equal(calls.length, 3) + assert.equal( + (calls[2]?.headers as Record)?.["X-Hashcash"], + `1:${TOKEN}:AgAAAA`, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("MegaApiClient exposes and resumes a persisted session", async () => { + const originalFetch = globalThis.fetch + const masterKey = crypto.randomBytes(16) + const masterKeyB64 = bytesToB64Url(masterKey) + const client = new MegaApiClient({ + email: "user@example.com", + password: "password123", + }) + + assert.equal(client.getSession(), null) + client.restoreSession("sid-1", masterKeyB64) + assert.deepEqual(client.getSession(), { + sid: "sid-1", + masterKey: masterKeyB64, + }) + assert.throws( + () => client.restoreSession("sid-1", bytesToB64Url(new Uint8Array(8))), + /invalid master key/, + ) + + const urls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + urls.push(String(input)) + return jsonResponse([{ f: [] }]) + }) as typeof fetch + + try { + await client.fetchNodes() + assert.match(urls[0], /&sid=sid-1$/) + } finally { + globalThis.fetch = originalFetch + } + + client.clearSession() + assert.equal(client.getSession(), null) +}) diff --git a/src/backend/drivers/mega/util.ts b/src/backend/drivers/mega/util.ts index 7890208c..66add874 100644 --- a/src/backend/drivers/mega/util.ts +++ b/src/backend/drivers/mega/util.ts @@ -1,4 +1,19 @@ import CryptoJS from "crypto-js" +import { + aesCbcDecrypt, + aesCbcEncrypt, + aesEcbDecrypt, + aesEcbEncrypt, + b64ToBytes, + bytesToB64Url, + decodeMegaPrivateKey, + decryptSessionId, + nodeCipherKey, + prepareKey, + prepareKeyV2, + stringhash, + utf8ToBytes, +} from "./crypto" import { MegaAddition, MegaNodeItem } from "./types" export function b64ToWords(s: string): CryptoJS.lib.WordArray { @@ -20,32 +35,252 @@ export function strToWords(s: string): CryptoJS.lib.WordArray { return CryptoJS.enc.Utf8.parse(s) } -export function prepareKey(password: string): CryptoJS.lib.WordArray { - const pWords = strToWords(password) - const key = [0x93c467e3, 0x7db0c7a4, 0xd1be3f81, 0x0152cb56] +const HASHCASH_TOKEN_BYTES = 48 +const HASHCASH_TOKEN_BLOCKS = 262_144 +const HASHCASH_BUFFER_BYTES = 4 + HASHCASH_TOKEN_BYTES * HASHCASH_TOKEN_BLOCKS +const HASHCASH_MAX_ATTEMPTS = 4096 +const HASHCASH_MAX_DURATION_MS = 10_000 +/** + * 预算 = 期望尝试数 × 该倍数。取 4:命中率 1-(1-1/E)^(4E) ≈ 98.2%, + * 而 easiness=192(E=256)时 1024 次 × 7.5 ms ≈ 7.7 s,仍在 10 s 墙钟内。 + * 2 倍只有 86.5%,为省尾部开销把失败率抬到 13.5% 不划算。 + */ +const HASHCASH_BUDGET_MULTIPLIER = 4 + +export interface MegaHashcashOptions { + /** Test seam; production uses the Worker-compatible WebCrypto SHA-256 API. */ + digest?: (data: Uint8Array) => Promise + maxAttempts?: number + maxDurationMs?: number + now?: () => number +} - // In Mega crypto, string is converted to words and iterated - const len = Math.max(pWords.sigBytes, 16) - const p: number[] = [] - for (let i = 0; i < len; i += 4) { - p.push(((pWords.words[i >> 2] || 0) >> (24 - (i % 4) * 8)) & 0xff) +function decodeHashcashToken(token: string): Uint8Array { + if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(token)) { + throw new Error( + "MEGA Hashcash challenge contains an invalid token encoding", + ) + } + const words = b64ToWords(token) + if (words.sigBytes !== HASHCASH_TOKEN_BYTES) { + throw new Error( + `MEGA Hashcash token must decode to ${HASHCASH_TOKEN_BYTES} bytes`, + ) } + const bytes = new Uint8Array(words.sigBytes) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = (words.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff + } + return bytes +} - // Key derivation using AES ECB - const keyWords = CryptoJS.lib.WordArray.create(key) - return keyWords +/** + * Solve the MEGA 402 Hashcash challenge used by the official web client. + * The work factor is deliberately bounded for serverless runtimes; a challenge + * that exceeds the budget is reported instead of consuming unbounded CPU. + * + * 成本提示:每次尝试都要对 ~12 MiB 缓冲做一次完整 SHA-256。本地 Node + * (WebCrypto)实测 easiness=192(期望 ~256 次)约需 0.4–2 s。真正的 CPU + * 开销发生在原生 crypto.subtle 里(Workers 的 cpuTime 基本不增长,参考 + * wallTime 53 s / cpuTime 61 ms 的日志),免费版 10 ms CPU 仍然不可用, + * 付费版 30 s 内可以完成。这里的预算按「期望尝试数 × 4」收敛,并保留 + * 10 s 墙钟上限:预算耗尽时报错退出,不会把请求拖到平台超时。 + */ +export async function generateHashcashToken( + challenge: string, + options: MegaHashcashOptions = {}, +): Promise { + if (!challenge || challenge.length > 512) { + throw new Error("MEGA Hashcash challenge is missing or too large") + } + const parts = challenge.split(":") + const version = Number(parts[0]) + const easiness = Number(parts[1]) + const tokenText = parts[3] || "" + if ( + parts.length !== 4 || + version !== 1 || + !Number.isInteger(easiness) || + easiness < 0 || + easiness > 255 + ) { + throw new Error("MEGA returned an unsupported Hashcash challenge") + } + + const token = decodeHashcashToken(tokenText) + const base = ((easiness & 63) << 1) + 1 + const shifts = (easiness >> 6) * 7 + 3 + const threshold = base * 2 ** shifts + const buffer = new Uint8Array(HASHCASH_BUFFER_BYTES) + for (let i = 0; i < HASHCASH_TOKEN_BLOCKS; i++) { + buffer.set(token, 4 + i * HASHCASH_TOKEN_BYTES) + } + + const subtle = globalThis.crypto?.subtle + if (!options.digest && !subtle) { + throw new Error("WebCrypto SHA-256 is unavailable for MEGA Hashcash") + } + const digest = + options.digest || + (async (data: Uint8Array) => subtle.digest("SHA-256", data as BufferSource)) + // 命中概率 ≈ (threshold + 1) / 2^32,故期望尝试次数 = 2^32 / (threshold + 1)。 + // 固定 4096 次在 easiness=192 时(期望 256 次)意味着 16 倍的无效哈希; + // 改成按期望值放大 4 倍:成功率约 98.2%,仍落在 10 s 墙钟内。 + const expectedAttempts = Math.ceil(2 ** 32 / (threshold + 1)) + const budgetAttempts = Math.min( + HASHCASH_MAX_ATTEMPTS, + Math.max(1, expectedAttempts * HASHCASH_BUDGET_MULTIPLIER), + ) + const maxAttempts = Math.min( + Math.max(1, Math.floor(options.maxAttempts ?? budgetAttempts)), + HASHCASH_MAX_ATTEMPTS, + ) + const maxDurationMs = Math.max( + 1, + Math.floor(options.maxDurationMs ?? HASHCASH_MAX_DURATION_MS), + ) + const now = options.now || Date.now + const startedAt = now() + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (now() - startedAt > maxDurationMs) break + + // The official client increments the 4-byte prefix little-endian before + // hashing, then reads the first digest word as big-endian DataView does. + for (let byte = 0; byte < 4; byte++) { + buffer[byte] = (buffer[byte] + 1) & 0xff + if (buffer[byte] !== 0) break + } + + const result = await digest(buffer) + const hash = result instanceof Uint8Array ? result : new Uint8Array(result) + if (hash.length < 4) { + throw new Error("SHA-256 digest returned fewer than four bytes") + } + const firstWord = + hash[0] * 0x1000000 + (hash[1] << 16) + (hash[2] << 8) + hash[3] + if (firstWord <= threshold) { + const prefix = CryptoJS.lib.WordArray.create( + [(buffer[0] << 24) | (buffer[1] << 16) | (buffer[2] << 8) | buffer[3]], + 4, + ) + return `1:${tokenText}:${wordsToB64(prefix)}` + } + } + + throw new Error( + `MEGA Hashcash proof exceeded the serverless work budget (${maxAttempts} attempts or ${maxDurationMs} ms)`, + ) +} + +const MAX_HASHCASH_RETRIES = 1 +const MAX_TRANSIENT_RETRIES = 4 +const TRANSIENT_RETRY_BASE_MS = 400 +const TRANSIENT_RETRY_MAX_MS = 8_000 +/** MEGA 的网关过载/限流状态码;402(Hashcash)由上面的分支单独处理。 */ +const TRANSIENT_HTTP_STATUS = new Set([429, 500, 502, 503, 504]) + +const MEGA_ERROR_NAMES: Record = { + [-1]: "EINTERNAL", + [-2]: "EARGS", + [-3]: "EAGAIN", + [-4]: "ERATELIMIT", + [-6]: "ETOOMANY", + [-9]: "ENOENT", + [-11]: "EACCESS", + [-15]: "ESID", + [-16]: "EBLOCKED", + [-17]: "EOVERQUOTA", + [-18]: "ETEMPUNAVAIL", + [-26]: "EMFAREQUIRED", +} + +/** MEGA 在 HTTP 200 里用负数返回 API 错误,单独建类型便于上层按 code 处理。 */ +export class MegaApiError extends Error { + readonly code: number + + constructor(code: number) { + const name = MEGA_ERROR_NAMES[code] + super(`Mega API error code: ${code}${name ? ` (${name})` : ""}`) + this.name = "MegaApiError" + this.code = code + } } +/** + * 只读响应体的前 limit 字节,读完立刻 cancel。 + * + * 错误信息只用于日志与前端提示,但 res.text() 会把整页 HTML 完整缓冲进内存 + * 再截断——截断输出并不等于截断读取。这里按流读取到上限就放弃剩余字节。 + */ +async function readTextPrefix(res: Response, limit: number): Promise { + const reader = res.body?.getReader() + if (!reader) return "" + const chunks: Uint8Array[] = [] + let total = 0 + try { + while (total < limit) { + const { done, value } = await reader.read() + if (done) break + if (!value || value.length === 0) continue + const rest = limit - total + const part = value.length > rest ? value.subarray(0, rest) : value + chunks.push(part) + total += part.length + } + } catch { + // 读取中断时使用已读到的部分 + } finally { + try { + await reader.cancel() + } catch { + // 已关闭的流 cancel 会抛错,忽略 + } + } + const merged = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + merged.set(chunk, offset) + offset += chunk.length + } + return new TextDecoder().decode(merged) +} + +/** MEGA 的节点属性结构是 "MEGA" + JSON,末尾用 NUL 补齐到 16 字节。 */ +function packAttributes(attributes: Record): Uint8Array { + const text = `MEGA${JSON.stringify(attributes)}` + const bytes = utf8ToBytes(text) + const length = Math.max(16, Math.ceil(bytes.length / 16) * 16) + const padded = new Uint8Array(length) + padded.set(bytes) + return padded +} + +const ZERO_IV = new Uint8Array(16) + +const defaultSleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + export class MegaApiClient { private addition: MegaAddition + private solveHashcash: (challenge: string) => Promise + private sleep: (ms: number) => Promise private sid = "" private seq = Math.floor(Math.random() * 0x10000000) - private masterKey: CryptoJS.lib.WordArray | null = null + private masterKey: Uint8Array | null = null private nodes: Map = new Map() private rootId = "" - constructor(addition: MegaAddition) { + constructor( + addition: MegaAddition, + solveHashcash: ( + challenge: string, + ) => Promise = generateHashcashToken, + sleep: (ms: number) => Promise = defaultSleep, + ) { this.addition = addition + this.solveHashcash = solveHashcash + this.sleep = sleep } private nextSeq(): number { @@ -53,6 +288,22 @@ export class MegaApiClient { return this.seq } + /** + * 指数退避。MEGA 的 "Server Too Busy"(503)通常几秒内恢复;Retry-After + * 存在时取两者较大值(上限 15 s),并加少量抖动避免同一时刻重试撞车。 + */ + private async retryDelay(attempt: number, retryAfter?: string | null) { + let delay = Math.min( + TRANSIENT_RETRY_MAX_MS, + TRANSIENT_RETRY_BASE_MS * 2 ** attempt, + ) + const header = Number(retryAfter) + if (Number.isFinite(header) && header > 0) { + delay = Math.max(delay, Math.min(header * 1000, 15_000)) + } + await this.sleep(delay + Math.floor(Math.random() * 250)) + } + async request(body: any[]): Promise { const seq = this.nextSeq() let url = `https://g.api.mega.co.nz/cs?id=${seq}` @@ -60,120 +311,265 @@ export class MegaApiClient { url += `&sid=${this.sid}` } - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) + const bodyText = JSON.stringify(body) + let hashcashHeader: string | undefined + let hashcashRetries = 0 + let transientRetries = 0 - if (!res.ok) { - throw new Error(`Mega API HTTP error: ${res.status}`) - } + for (;;) { + const headers: Record = { + "Content-Type": "application/json", + } + if (hashcashHeader) headers["X-Hashcash"] = hashcashHeader + + const res = await fetch(url, { + method: "POST", + headers, + body: bodyText, + }) + + // MEGA signals a proof-of-work challenge with HTTP 402 + X-Hashcash. + // Other 402 responses (for example, payment/account errors) keep the + // ordinary error path; challenges are bounded to one retry per request. + // 注意:挑战绑定本次请求的 id,重试必须复用同一个 url/seq,不能重新生成。 + if (res.status === 402) { + const challenge = res.headers.get("X-Hashcash") + if (challenge) { + if (hashcashRetries >= MAX_HASHCASH_RETRIES) { + throw new Error( + "Mega API repeated the Hashcash challenge after retry", + ) + } + hashcashRetries++ + try { + await res.body?.cancel() + } catch { + // The response body is not needed after extracting its challenge header. + } + hashcashHeader = await this.solveHashcash(challenge) + continue + } + } - const json = (await res.json()) as any - if (typeof json === "number" && json < 0) { - throw new Error(`Mega API error code: ${json}`) + // 503 "Server Too Busy" / 429 / 502 等是网关过载或限流,和 PoW 无关, + // 官方客户端与 megajs 都是退避后重试;这里做同样的有界重试。 + if ( + TRANSIENT_HTTP_STATUS.has(res.status) && + transientRetries < MAX_TRANSIENT_RETRIES + ) { + const retryAfter = res.headers.get("Retry-After") + try { + await res.body?.cancel() + } catch { + // 丢弃响应体,避免连接被占用 + } + await this.retryDelay(transientRetries++, retryAfter) + continue + } + + if (!res.ok) { + // 上游异常时可能返回整页 HTML;错误信息会进日志与前端提示,必须截断。 + // 用流式读取而不是 res.text().slice():后者仍会把整页正文缓冲进内存。 + const detail = await readTextPrefix(res, 512) + const statusText = res.statusText ? ` ${res.statusText}` : "" + throw new Error( + `Mega API HTTP error: ${res.status}${statusText}${detail ? `: ${detail}` : ""}`, + ) + } + + const json = (await res.json()) as any + // MEGA 的 API 错误既可能是裸数字,也可能是单元素数组(如 [-9])。 + const code = + typeof json === "number" + ? json + : Array.isArray(json) && + json.length === 1 && + typeof json[0] === "number" + ? json[0] + : undefined + if (code !== undefined && code < 0) { + // -3 EAGAIN(临时拥塞)与 -4 ERATELIMIT(命令配额)同样值得重试。 + if ( + (code === -3 || code === -4) && + transientRetries < MAX_TRANSIENT_RETRIES + ) { + await this.retryDelay(transientRetries++) + continue + } + throw new MegaApiError(code) + } + return json as T } - return json as T } + /** + * 完整登录流程(与官方客户端 / megajs 一致): + * us0 预登录拿到账号版本(v1/v2)→ 派生用户密钥与 uh → us 登录 → + * 用用户密钥解出 master key → 解 privk → RSA 解 csid 得到 sid。 + * + * 旧实现只发 `us` 且把 uh 写成 base64url(email),402 消失后服务端就会 + * 回 -9(ENOENT / 用户不存在),这就是 issue #150 里 PR #166 之后的报错。 + */ async init(): Promise { if (!this.addition.email || !this.addition.password) { throw new Error("Mega email and password are required") } - const userHash = CryptoJS.enc.Base64.stringify( - CryptoJS.enc.Utf8.parse(this.addition.email.toLowerCase()), - ) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, "") + const email = this.addition.email.trim().toLowerCase() + const password = this.addition.password - // Step 1: user challenge 'us' - const res = await this.request([ - { - a: "us", - user: this.addition.email.toLowerCase(), - uh: userHash, - }, - ]) + this.clearSession() - const authResp = res[0] + const prelogin = await this.request([{ a: "us0", user: email }]) + const preloginResp = prelogin[0] + if (typeof preloginResp === "number" && preloginResp < 0) { + throw new Error(`Mega prelogin failed: ${preloginResp}`) + } + + const version = preloginResp?.v + let userKey: Uint8Array + let userHandle: string + if (version === 1) { + userKey = prepareKey(password) + userHandle = bytesToB64Url(stringhash(email, userKey)) + } else if (version === 2) { + if (!preloginResp?.s) { + throw new Error("Mega prelogin did not return an account salt") + } + const derived = await prepareKeyV2(password, b64ToBytes(preloginResp.s)) + userKey = derived.subarray(0, 16) + userHandle = bytesToB64Url(derived.subarray(16, 32)) + } else { + throw new Error(`Mega account version not supported: ${version}`) + } + + const loginBody: Record = { + a: "us", + user: email, + uh: userHandle, + } + const twoFa = this.addition.two_fa_code?.trim() + if (twoFa) loginBody.mfa = twoFa + + const login = await this.request([loginBody]).catch((err) => { + if (err instanceof MegaApiError) { + throw new Error(this.authErrorMessage(err.code)) + } + throw err + }) + const authResp = login[0] if (typeof authResp === "number" && authResp < 0) { - throw new Error(`Mega authentication failed: ${authResp}`) + throw new Error(this.authErrorMessage(authResp)) + } + if (!authResp?.k || !authResp?.privk || !authResp?.csid) { + throw new Error("Mega login response is missing session keys") } - this.sid = authResp.sid || "" + // k: 账号 master key,用用户密钥 AES-ECB 解出;privk/csid 再用 master key + // 解出 RSA 私钥与密文,最终 sid 只能通过 RSA 解密得到,响应里没有现成 sid。 + this.masterKey = aesEcbDecrypt(userKey, b64ToBytes(String(authResp.k))) + if (this.masterKey.length !== 16) { + throw new Error("Mega login returned an invalid master key") + } + const privateKey = decodeMegaPrivateKey( + aesEcbDecrypt(this.masterKey, b64ToBytes(String(authResp.privk))), + ) + this.sid = decryptSessionId(privateKey, b64ToBytes(String(authResp.csid))) await this.fetchNodes() } + private authErrorMessage(code: number): string { + switch (code) { + case -9: + return "Mega authentication failed: -9 (wrong email or password)" + case -16: + return "Mega authentication failed: -16 (account blocked)" + case -26: + return "Mega authentication failed: -26 (2FA code required or invalid)" + default: + return `Mega authentication failed: ${code}` + } + } + async fetchNodes(): Promise { if (!this.sid) return const res = await this.request([{ a: "f", c: 1 }]) const filesResp = res[0] - if (filesResp?.f && Array.isArray(filesResp.f)) { - this.nodes.clear() - for (const node of filesResp.f) { - // Node type: 0=file, 1=folder, 2=root, 3=inbox, 4=trash - const isDir = - node.t === 1 || node.t === 2 || node.t === 3 || node.t === 4 - if (node.t === 2) { - this.rootId = node.h - } - - let name = "unnamed" - if (node.a) { - try { - // Decrypt attribute string - const decrypted = this.decryptAttributes(node.a, node.k) - if (decrypted?.n) { - name = decrypted.n - } - } catch { - name = node.h - } - } + this.nodes.clear() + this.rootId = "" + if (!Array.isArray(filesResp?.f)) return + + for (const node of filesResp.f) { + // Node type: 0=file, 1=folder, 2=root, 3=inbox, 4=trash + const isDir = node.t === 1 || node.t === 2 || node.t === 3 || node.t === 4 + if (node.t === 2) { + this.rootId = node.h + } - const item: MegaNodeItem = { - id: node.h, - parent_id: node.p, - name, - size: node.s || 0, - is_dir: isDir, - modified: node.ts - ? new Date(node.ts * 1000).toISOString() - : new Date().toISOString(), - type: node.t, - key: node.k, - } - this.nodes.set(node.h, item) + const nodeKey = this.decryptNodeKey(node.k) + let name = node.h + const attributes = this.decryptAttributes(node.a, nodeKey) + if (attributes?.n) name = attributes.n + + const item: MegaNodeItem = { + id: node.h, + parent_id: node.p, + name, + size: node.s || 0, + is_dir: isDir, + modified: node.ts + ? new Date(node.ts * 1000).toISOString() + : new Date().toISOString(), + type: node.t, + key: node.k, + file_key: nodeKey ? bytesToB64Url(nodeKey) : undefined, } + this.nodes.set(node.h, item) + } + } + + /** 节点 k 可能是 "handle:key";共享节点是 RSA 密钥(>32 字节),这里跳过。 */ + private decryptNodeKey(keyText?: string): Uint8Array | null { + if (!keyText || !this.masterKey) return null + try { + const parts = keyText.split(":") + const key = b64ToBytes(parts[parts.length - 1]) + if (key.length !== 16 && key.length !== 32) return null + return aesEcbDecrypt(this.masterKey, key) + } catch { + return null } } private decryptAttributes( - attrB64: string, - keyStr?: string, + attrB64?: string, + nodeKey?: Uint8Array | null, ): { n?: string } | null { + if (!attrB64 || !nodeKey) return null try { - const words = b64ToWords(attrB64) - const text = CryptoJS.enc.Utf8.stringify(words) - if (text.startsWith("MEGA{")) { - const jsonStr = text.substring(4) - return JSON.parse(jsonStr) - } + const plain = aesCbcDecrypt( + nodeCipherKey(nodeKey), + ZERO_IV, + b64ToBytes(attrB64), + ) + let end = 0 + while (end < plain.length && plain[end] !== 0) end++ + const text = new TextDecoder().decode(plain.subarray(0, end)) + if (!text.startsWith("MEGA{")) return null + const parsed = JSON.parse(text.substring(4)) + return typeof parsed?.n === "string" ? parsed : null } catch { - // Ignored + return null } - return null } getChildren(parentId?: string): MegaNodeItem[] { const targetParent = parentId || this.rootId const result: MegaNodeItem[] = [] for (const node of this.nodes.values()) { - if (node.parent_id === targetParent) { + // 根节点的 parent 指向自身,列目录时必须排除它,否则根目录会把自己列出来。 + if (node.id !== targetParent && node.parent_id === targetParent) { result.push(node) } } @@ -188,6 +584,29 @@ export class MegaApiClient { return this.rootId } + /** 持久化用的会话材料;master key 是解密节点密钥/属性所必需的。 */ + getSession(): { sid: string; masterKey: string } | null { + if (!this.sid || !this.masterKey) return null + return { sid: this.sid, masterKey: bytesToB64Url(this.masterKey) } + } + + /** 复用已持久化的会话,跳过 us0/us 登录(也就跳过了 Hashcash)。 */ + restoreSession(sid: string, masterKey: string): void { + const key = b64ToBytes(masterKey) + if (key.length !== 16) { + throw new Error("Mega persisted session has an invalid master key") + } + this.sid = sid + this.masterKey = key + } + + clearSession(): void { + this.sid = "" + this.masterKey = null + this.nodes.clear() + this.rootId = "" + } + async getDownloadLink(handle: string): Promise { const res = await this.request([{ a: "g", g: 1, n: handle }]) const data = res[0] @@ -198,9 +617,17 @@ export class MegaApiClient { } async createFolder(name: string, parentId?: string): Promise { + if (!this.masterKey) throw new Error("Mega client is not logged in") const pid = parentId || this.rootId - const attrJson = JSON.stringify({ n: name }) - const attrB64 = wordsToB64(strToWords(`MEGA${attrJson}`)) + // 新目录需要一把随机 16 字节密钥,属性用它加密,密钥本身再用账号 + // master key 加密后随节点一起提交,服务端不参与明文。 + const folderKey = globalThis.crypto.getRandomValues(new Uint8Array(16)) + const encryptedKey = aesEcbEncrypt(this.masterKey, folderKey) + const encryptedAttrs = aesCbcEncrypt( + nodeCipherKey(folderKey), + ZERO_IV, + packAttributes({ n: name }), + ) const res = await this.request([ { @@ -210,8 +637,8 @@ export class MegaApiClient { { h: "xxxxxxxx", t: 1, - a: attrB64, - k: "dummy_key", + a: bytesToB64Url(encryptedAttrs), + k: bytesToB64Url(encryptedKey), }, ], }, diff --git a/src/backend/internal/op/storage.ts b/src/backend/internal/op/storage.ts index ef4d6f25..e0a5f1b4 100644 --- a/src/backend/internal/op/storage.ts +++ b/src/backend/internal/op/storage.ts @@ -1045,7 +1045,40 @@ async function createDriver( normDriver.includes("mega") ) { const addition = parseAddition(storageConfig) - driver = new MegaDriver(addition) + driver = new MegaDriver(addition, async (session) => { + try { + const db = await getDb() + const st = (db.storages || []).find((s: any) => s.id === storageConfig?.id) + if (!st) return + const stAddition = + typeof st.addition === "string" + ? JSON.parse(st.addition || "{}") + : st.addition || {} + if ( + stAddition.sid === session.sid && + stAddition.master_key === session.masterKey + ) { + return + } + stAddition.sid = session.sid + stAddition.master_key = session.masterKey + st.addition = JSON.stringify(stAddition) + // /admin/storage/update 会用提交前的 updatedStorage 覆盖 db,这里同步 + // 写回它,避免刚拿到的会话被旧快照冲掉(与 139/GuangYaPan 的处理一致)。 + if (deferredTokenPersistence.has(storageConfig)) { + storageConfig.addition = JSON.stringify({ + ...parseAddition(storageConfig), + sid: session.sid, + master_key: session.masterKey, + }) + return + } + storageConfig.addition = st.addition + await saveDb(db) + } catch (e) { + console.warn("[Mega] failed to persist session:", e) + } + }) await driver.init?.() } else if ( normDriver === "115share" ||