From 6386a0ec9f7ebcaa6f90c3b6eb501c1d7c46e586 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 11 May 2026 00:58:27 -0700 Subject: [PATCH] Phase 3a red: auth.unwrapAuth tests and stub 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. --- src/auth/types.ts | 53 +++++++++ src/auth/unwrap.ts | 17 +++ test/auth/unwrap.test.ts | 243 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 src/auth/types.ts create mode 100644 src/auth/unwrap.ts create mode 100644 test/auth/unwrap.test.ts diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..7190c9e --- /dev/null +++ b/src/auth/types.ts @@ -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=. +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" }; diff --git a/src/auth/unwrap.ts b/src/auth/unwrap.ts new file mode 100644 index 0000000..78ca171 --- /dev/null +++ b/src/auth/unwrap.ts @@ -0,0 +1,17 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +import type { AuthorizationResponse } from "./types.js"; + +export interface UnwrapResult { + masterKey: Uint8Array; + secretKey: Uint8Array; + publicKey: Uint8Array; + token: string; +} + +export const unwrapAuth = async ( + _response: AuthorizationResponse, + _password: string, +): Promise => { + throw new Error("auth.unwrapAuth not implemented"); +}; diff --git a/test/auth/unwrap.test.ts b/test/auth/unwrap.test.ts new file mode 100644 index 0000000..082fb8e --- /dev/null +++ b/test/auth/unwrap.test.ts @@ -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/, + ); + }); +});