Merge: Phase 3a auth/unwrap

unwrapAuth: password to master/secret/public key and auth token. The
password-only side of Ente login. Built test-first; 6 tests describe
the protocol and verify byte-for-byte recovery of the inputs from a
synthetic AuthorizationResponse.
This commit is contained in:
2026-05-11 00:59:45 -07:00
5 changed files with 372 additions and 2 deletions

View File

@@ -611,8 +611,10 @@ Phase 3: SRP + auth
- [ ] `beginLogin(email, password)` returning a `LoginChallenge` - [ ] `beginLogin(email, password)` returning a `LoginChallenge`
- [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP - [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP
- [ ] `submitTOTP(sessionID, code)` - [ ] `submitTOTP(sessionID, code)`
- [ ] `unwrapAuth(response, password)` returning master key, secret key, public - [x] `unwrapAuth(response, password)` returning master key, secret key, public
key, and decrypted token key, and decrypted token (URL-safe-no-padding base64)
- [x] `src/auth/types.ts` with `KeyAttributes`, `SRPAttributes`,
`AuthorizationResponse`, and `LoginChallenge`
- [ ] Tests against recorded HTTP fixtures - [ ] Tests against recorded HTTP fixtures
Phase 4: HTTP client + endpoints Phase 4: HTTP client + endpoints

53
src/auth/types.ts Normal file
View File

@@ -0,0 +1,53 @@
// Base64-encoded binary, in either standard or URL-safe form. Decoded with
// crypto.fromBase64 which accepts either variant.
export type Base64 = string;
// The blob the server returns alongside the auth token after a successful
// login. Together with the user's password, this is everything quack needs
// to derive the master key, secret key, and auth token.
export interface KeyAttributes {
kekSalt: Base64;
encryptedKey: Base64;
keyDecryptionNonce: Base64;
publicKey: Base64;
encryptedSecretKey: Base64;
secretKeyDecryptionNonce: Base64;
memLimit: number;
opsLimit: number;
masterKeyEncryptedWithRecoveryKey?: Base64;
masterKeyDecryptionNonce?: Base64;
recoveryKeyEncryptedWithMasterKey?: Base64;
recoveryKeyDecryptionNonce?: Base64;
}
// Server-issued attributes needed to start an SRP-6a handshake. Returned
// from GET /users/srp/attributes?email=<email>.
export interface SRPAttributes {
srpUserID: string;
srpSalt: Base64;
memLimit: number;
opsLimit: number;
kekSalt: Base64;
isEmailMFAEnabled: boolean;
}
// The body of a successful login response. Exactly one of the following
// situations applies, and the caller dispatches on the populated fields:
// - both keyAttributes and encryptedToken present: login is complete
// - twoFactorSessionID present: caller must submit a TOTP code
// - passkeySessionID present: caller must complete a passkey ceremony
export interface AuthorizationResponse {
id: number;
keyAttributes?: KeyAttributes;
encryptedToken?: Base64;
twoFactorSessionID?: string;
passkeySessionID?: string;
}
// Discriminated union returned by the high-level login functions to tell
// the caller what to do next.
export type LoginChallenge =
| { kind: "complete"; response: AuthorizationResponse }
| { kind: "totp"; sessionID: string }
| { kind: "passkey"; sessionID: string }
| { kind: "emailOTP" };

64
src/auth/unwrap.ts Normal file
View File

@@ -0,0 +1,64 @@
import {
decryptBox,
decryptSealed,
deriveKEK,
fromBase64,
toBase64URL,
} from "../crypto/index.js";
import type { AuthorizationResponse } from "./types.js";
export interface UnwrapResult {
masterKey: Uint8Array;
secretKey: Uint8Array;
publicKey: Uint8Array;
// URL-safe-no-padding base64 of the decrypted token bytes; this is the
// exact value to put in the X-Auth-Token header on subsequent calls.
token: string;
}
// Given a successful AuthorizationResponse and the user's password,
// derive the KEK, decrypt the master key, decrypt the secret key, and
// open the sealed auth token. See test/auth/unwrap.test.ts for the
// complete protocol description.
export const unwrapAuth = async (
response: AuthorizationResponse,
password: string,
): Promise<UnwrapResult> => {
if (!response.keyAttributes) {
throw new Error(
"unwrapAuth: response.keyAttributes is required (login may be incomplete: TOTP or passkey pending)",
);
}
if (!response.encryptedToken) {
throw new Error(
"unwrapAuth: response.encryptedToken is required (login may be incomplete: TOTP or passkey pending)",
);
}
const ka = response.keyAttributes;
const kek = await deriveKEK(
password,
fromBase64(ka.kekSalt),
ka.opsLimit,
ka.memLimit,
);
const masterKey = decryptBox(
fromBase64(ka.encryptedKey),
fromBase64(ka.keyDecryptionNonce),
kek,
);
const secretKey = decryptBox(
fromBase64(ka.encryptedSecretKey),
fromBase64(ka.secretKeyDecryptionNonce),
masterKey,
);
const publicKey = fromBase64(ka.publicKey);
const tokenBytes = decryptSealed(
fromBase64(response.encryptedToken),
publicKey,
secretKey,
);
const token = toBase64URL(tokenBytes);
return { masterKey, secretKey, publicKey, token };
};

View File

@@ -1 +1,9 @@
export const VERSION = "0.0.0"; export const VERSION = "0.0.0";
export { unwrapAuth, type UnwrapResult } from "./auth/unwrap.js";
export type {
AuthorizationResponse,
KeyAttributes,
LoginChallenge,
SRPAttributes,
} from "./auth/types.js";

243
test/auth/unwrap.test.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* 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/,
);
});
});