The Ente server validates the auth token as URL-safe base64 with padding (matching Go's base64.URLEncoding). Our toBase64URL strips padding, producing a 43-char token where the server expects 44. This caused HTTP 401 'invalid token' on every authenticated call. Adds toBase64URLPadded to the crypto module and uses it in unwrapAuth for the token specifically. toBase64URL (no-padding) is kept for general use (JWT-style contexts). Adds test/integration/live-login.ts which logs into the dev account (entedev2026jp@acidhou.se), unwraps keys, and fetches collections from the real Ente API. Verified: 4 collections returned successfully.
246 lines
9.3 KiB
TypeScript
246 lines
9.3 KiB
TypeScript
/**
|
|
* 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/,
|
|
);
|
|
});
|
|
});
|