Tests for the password-only decryption chain that follows a successful login (SRP or email OTP, with or without 2FA). The unwrap covers: password -> KEK (Argon2id) -> masterKey (secretbox) -> secretKey (secretbox) -> tokenBytes (sealed box) -> base64url token Each test builds a synthetic AuthorizationResponse using libsodium directly and asserts unwrapAuth recovers the inputs byte for byte. The test file also functions as the canonical description of the protocol. Adds src/auth/types.ts with KeyAttributes, SRPAttributes, AuthorizationResponse, and LoginChallenge declarations matching the README's API reference. src/auth/unwrap.ts is the throwing stub; the real implementation lands next.
244 lines
9.2 KiB
TypeScript
244 lines
9.2 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
|
|
* │
|
|
* ▼ toBase64URL
|
|
* 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, toBase64URL } 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.
|
|
expect(result.token).toBe(toBase64URL(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/,
|
|
);
|
|
});
|
|
});
|