/** * Tests for `auth.unwrapAuth`. * * `unwrapAuth` is the second half of Ente's login flow. After the user * authenticates (via SRP or email OTP, with optional 2FA), the server * returns an AuthorizationResponse containing: * * - a `keyAttributes` blob with the user's encrypted master key, * encrypted secret key, plaintext public key, and the KDF parameters * used to derive the KEK from the password; * - an `encryptedToken` field with the auth token sealed to the user's * public key. * * `unwrapAuth(response, password)` performs the entire decryption chain: * * password * │ * ▼ Argon2id(kekSalt, opsLimit, memLimit) * KEK * │ * ▼ secretbox_open(encryptedKey, keyDecryptionNonce, KEK) * masterKey ────────────────────────────────────────────┐ * │ │ * ▼ secretbox_open(encryptedSecretKey, │ (returned) * secretKeyDecryptionNonce, masterKey) │ * secretKey │ * │ │ * │ publicKey is delivered in cleartext │ * │ │ * ▼ sealed_box_open(encryptedToken, publicKey, secretKey) * tokenBytes * │ * ▼ toBase64URLPadded * token ───── X-Auth-Token header value * * No HTTP happens here. The caller is responsible for the round trip * that produced the AuthorizationResponse. `unwrapAuth` is the * password-only side of the protocol. * * The test below builds a synthetic AuthorizationResponse using * libsodium directly, then asserts `unwrapAuth` recovers the inputs. * That double-checks both the byte format and the order of operations. */ import sodium from "libsodium-wrappers-sumo"; import { beforeAll, describe, expect, it } from "vitest"; import { init, toBase64, toBase64URLPadded } from "../../src/crypto/index.js"; import { unwrapAuth } from "../../src/auth/unwrap.js"; import type { AuthorizationResponse, KeyAttributes, } from "../../src/auth/types.js"; /** * Build a complete, valid AuthorizationResponse for `password` such that * the wrapped master key is `masterKey` and the sealed token bytes are * `tokenBytes`. The user's X25519 keypair is generated fresh. * * Argon2id is run at the cheapest valid parameters so the suite stays * under the test-time budget. The flow is identical at production * parameters; only the cost differs. */ const buildFixture = ( password: string, masterKey: Uint8Array, tokenBytes: Uint8Array, ): { response: AuthorizationResponse; publicKey: Uint8Array; secretKey: Uint8Array; } => { const opsLimit = 2; const memLimit = 64 * 1024 * 1024; // KEK = Argon2id(password, kekSalt, ops, mem) const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); const kek = sodium.crypto_pwhash( 32, password, kekSalt, opsLimit, memLimit, sodium.crypto_pwhash_ALG_ARGON2ID13, ); // encryptedKey = secretbox(masterKey, kek) const keyDecryptionNonce = sodium.randombytes_buf( sodium.crypto_secretbox_NONCEBYTES, ); const encryptedKey = sodium.crypto_secretbox_easy( masterKey, keyDecryptionNonce, kek, ); // The user's keypair; secret key is sealed under the master key. const kp = sodium.crypto_box_keypair(); const secretKeyDecryptionNonce = sodium.randombytes_buf( sodium.crypto_secretbox_NONCEBYTES, ); const encryptedSecretKey = sodium.crypto_secretbox_easy( kp.privateKey, secretKeyDecryptionNonce, masterKey, ); // encryptedToken = sealed-box(tokenBytes, publicKey) const encryptedToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); const keyAttributes: KeyAttributes = { kekSalt: toBase64(kekSalt), encryptedKey: toBase64(encryptedKey), keyDecryptionNonce: toBase64(keyDecryptionNonce), publicKey: toBase64(kp.publicKey), encryptedSecretKey: toBase64(encryptedSecretKey), secretKeyDecryptionNonce: toBase64(secretKeyDecryptionNonce), memLimit, opsLimit, }; const response: AuthorizationResponse = { id: 42, keyAttributes, encryptedToken: toBase64(encryptedToken), }; return { response, publicKey: kp.publicKey, secretKey: kp.privateKey }; }; describe("auth.unwrapAuth", () => { beforeAll(async () => { await init(); await sodium.ready; }); it("recovers masterKey, secretKey, publicKey, and token", async () => { // Fresh random master key and token, so the test exercises the // full path without any zeros-masking-bugs. const password = "correct horse battery staple"; const masterKey = sodium.randombytes_buf(32); const tokenBytes = sodium.randombytes_buf(64); const { response, publicKey, secretKey } = buildFixture( password, masterKey, tokenBytes, ); const result = await unwrapAuth(response, password); expect(result.masterKey).toEqual(masterKey); expect(result.secretKey).toEqual(secretKey); expect(result.publicKey).toEqual(publicKey); // Token is returned as URL-safe-no-padding base64 of the bytes // sealed by the server. The caller passes this string directly // as the X-Auth-Token header. // Token uses URL-safe base64 WITH padding to match the Go CLI's // base64.URLEncoding and the Ente server's token validation. expect(result.token).toBe(toBase64URLPadded(tokenBytes)); }); it("rejects a wrong password", async () => { // The first decryption that fails is the master-key secretbox. // Authentication failure surfaces as a thrown error, not silent // garbage data. If this test ever passes by returning bytes, // it means the Poly1305 check was bypassed somewhere. const password = "the right one"; const masterKey = sodium.randombytes_buf(32); const tokenBytes = sodium.randombytes_buf(64); const { response } = buildFixture(password, masterKey, tokenBytes); await expect(unwrapAuth(response, "the wrong one")).rejects.toThrow(); }); it("rejects a tampered encryptedKey", async () => { // Flip one byte of the master-key ciphertext after the fixture // is built. Decryption must reject; the user must see an error // rather than a silently-corrupted master key. const password = "x"; const masterKey = sodium.randombytes_buf(32); const tokenBytes = sodium.randombytes_buf(64); const { response } = buildFixture(password, masterKey, tokenBytes); // Decode, flip, re-encode. const encryptedKeyBytes = sodium.from_base64( response.keyAttributes!.encryptedKey, sodium.base64_variants.ORIGINAL, ); encryptedKeyBytes[0] = encryptedKeyBytes[0]! ^ 0x01; response.keyAttributes!.encryptedKey = sodium.to_base64( encryptedKeyBytes, sodium.base64_variants.ORIGINAL, ); await expect(unwrapAuth(response, password)).rejects.toThrow(); }); it("rejects a tampered encryptedToken", async () => { const password = "x"; const masterKey = sodium.randombytes_buf(32); const tokenBytes = sodium.randombytes_buf(64); const { response } = buildFixture(password, masterKey, tokenBytes); const encryptedTokenBytes = sodium.from_base64( response.encryptedToken!, sodium.base64_variants.ORIGINAL, ); encryptedTokenBytes[encryptedTokenBytes.length - 1] = encryptedTokenBytes[encryptedTokenBytes.length - 1]! ^ 0x01; response.encryptedToken = sodium.to_base64( encryptedTokenBytes, sodium.base64_variants.ORIGINAL, ); await expect(unwrapAuth(response, password)).rejects.toThrow(); }); it("rejects a response with no keyAttributes", async () => { // If the server returned a 2FA challenge instead of completing the // login, `keyAttributes` and `encryptedToken` are absent. The // caller should not be calling unwrapAuth in that state; throwing // is the right behaviour rather than silently producing nothing. const response: AuthorizationResponse = { id: 1, twoFactorSessionID: "abc", }; await expect(unwrapAuth(response, "x")).rejects.toThrow( /keyAttributes/, ); }); it("rejects a response with no encryptedToken", async () => { // Half-populated response (keyAttributes present but no token). // Same reasoning as above: throw rather than silently succeed. const password = "x"; const masterKey = sodium.randombytes_buf(32); const tokenBytes = sodium.randombytes_buf(64); const { response } = buildFixture(password, masterKey, tokenBytes); delete response.encryptedToken; await expect(unwrapAuth(response, password)).rejects.toThrow( /encryptedToken/, ); }); });