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:
@@ -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
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" };
|
||||||
64
src/auth/unwrap.ts
Normal file
64
src/auth/unwrap.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -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
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