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.
This commit is contained in:
53
src/auth/types.ts
Normal file
53
src/auth/types.ts
Normal 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" };
|
||||
17
src/auth/unwrap.ts
Normal file
17
src/auth/unwrap.ts
Normal file
@@ -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<UnwrapResult> => {
|
||||
throw new Error("auth.unwrapAuth not implemented");
|
||||
};
|
||||
243
test/auth/unwrap.test.ts
Normal file
243
test/auth/unwrap.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user