diff --git a/README.md b/README.md index f045495..7b3159a 100644 --- a/README.md +++ b/README.md @@ -611,8 +611,10 @@ Phase 3: SRP + auth - [ ] `beginLogin(email, password)` returning a `LoginChallenge` - [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP - [ ] `submitTOTP(sessionID, code)` -- [ ] `unwrapAuth(response, password)` returning master key, secret key, public - key, and decrypted token +- [x] `unwrapAuth(response, password)` returning master key, secret key, public + 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 Phase 4: HTTP client + endpoints 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..1a894df --- /dev/null +++ b/src/auth/unwrap.ts @@ -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 => { + 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 }; +}; diff --git a/src/index.ts b/src/index.ts index 2e47a88..073f505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,9 @@ export const VERSION = "0.0.0"; + +export { unwrapAuth, type UnwrapResult } from "./auth/unwrap.js"; +export type { + AuthorizationResponse, + KeyAttributes, + LoginChallenge, + SRPAttributes, +} from "./auth/types.js"; 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/, + ); + }); +});