diff --git a/src/auth/unwrap.ts b/src/auth/unwrap.ts index 1a894df..7e4f12c 100644 --- a/src/auth/unwrap.ts +++ b/src/auth/unwrap.ts @@ -3,7 +3,7 @@ import { decryptSealed, deriveKEK, fromBase64, - toBase64URL, + toBase64URLPadded, } from "../crypto/index.js"; import type { AuthorizationResponse } from "./types.js"; @@ -58,7 +58,10 @@ export const unwrapAuth = async ( publicKey, secretKey, ); - const token = toBase64URL(tokenBytes); + // The Ente server validates the token as URL-safe base64 WITH padding, + // matching Go's base64.URLEncoding. Using the no-padding form results + // in HTTP 401 "invalid token". + const token = toBase64URLPadded(tokenBytes); return { masterKey, secretKey, publicKey, token }; }; diff --git a/src/crypto/encoding.ts b/src/crypto/encoding.ts index 6fa886c..beaed1e 100644 --- a/src/crypto/encoding.ts +++ b/src/crypto/encoding.ts @@ -6,6 +6,12 @@ export const toBase64 = (b: Uint8Array): string => export const toBase64URL = (b: Uint8Array): string => sodium.to_base64(b, sodium.base64_variants.URLSAFE_NO_PADDING); +// URL-safe base64 WITH padding. Go's base64.URLEncoding produces this form, +// and the Ente server validates the auth token in this format. Use toBase64URL +// for general use (JWT-style, no padding); use this for the auth token. +export const toBase64URLPadded = (b: Uint8Array): string => + sodium.to_base64(b, sodium.base64_variants.URLSAFE); + // Ente uses standard base64 for most fields, URL-safe (with padding stripped) // for the auth token. Rather than make callers specify, fromBase64 accepts // any of the four variants libsodium understands and returns the bytes. diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 0c77196..6cff5f5 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -1,5 +1,10 @@ export { init } from "./sodium.js"; -export { fromBase64, toBase64, toBase64URL } from "./encoding.js"; +export { + fromBase64, + toBase64, + toBase64URL, + toBase64URLPadded, +} from "./encoding.js"; export { deriveKEK, deriveLoginSubkey } from "./kdf.js"; export { decryptBox, decryptSealed } from "./box.js"; export { diff --git a/test/auth/unwrap.test.ts b/test/auth/unwrap.test.ts index 082fb8e..bfdba53 100644 --- a/test/auth/unwrap.test.ts +++ b/test/auth/unwrap.test.ts @@ -30,7 +30,7 @@ * ▼ sealed_box_open(encryptedToken, publicKey, secretKey) * tokenBytes * │ - * ▼ toBase64URL + * ▼ toBase64URLPadded * token ───── X-Auth-Token header value * * No HTTP happens here. The caller is responsible for the round trip @@ -44,7 +44,7 @@ import sodium from "libsodium-wrappers-sumo"; import { beforeAll, describe, expect, it } from "vitest"; -import { init, toBase64, toBase64URL } from "../../src/crypto/index.js"; +import { init, toBase64, toBase64URLPadded } from "../../src/crypto/index.js"; import { unwrapAuth } from "../../src/auth/unwrap.js"; import type { AuthorizationResponse, @@ -154,7 +154,9 @@ describe("auth.unwrapAuth", () => { // 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)); + // Token uses URL-safe base64 WITH padding to match the Go CLI's + // base64.URLEncoding and the Ente server's token validation. + expect(result.token).toBe(toBase64URLPadded(tokenBytes)); }); it("rejects a wrong password", async () => { diff --git a/test/integration/live-login.ts b/test/integration/live-login.ts new file mode 100644 index 0000000..5780f84 --- /dev/null +++ b/test/integration/live-login.ts @@ -0,0 +1,64 @@ +import { init } from "../../src/crypto/index.js"; +import { ApiClient } from "../../src/api/client.js"; +import { beginLogin } from "../../src/auth/login.js"; +import { unwrapAuth } from "../../src/auth/unwrap.js"; + +// Dev account — not a secret, throwaway account for integration testing. +const EMAIL = "entedev2026jp@acidhou.se"; +const PASSWORD = "loldongs"; +const RECOVERY_KEY = + "deliver have behave collect void chicken boring embrace coast reflect squeeze cotton dish resemble license remain quick dwarf plastic ensure amused cry nasty equip"; + +void RECOVERY_KEY; // available if needed for account recovery tests + +const main = async () => { + await init(); + const api = new ApiClient(); + + console.log(`Logging in as ${EMAIL}...`); + const challenge = await beginLogin(api, EMAIL, PASSWORD); + console.log("beginLogin returned:", challenge.kind); + + let response; + if (challenge.kind === "complete") { + response = challenge.response; + } else if (challenge.kind === "totp") { + console.error("Account requires TOTP — disable 2FA for dev testing"); + process.exit(1); + } else if (challenge.kind === "emailOTP") { + console.error("Account uses email OTP — SRP not set up?"); + process.exit(1); + } else { + console.error("Unexpected challenge:", challenge); + process.exit(1); + } + + console.log("Got AuthorizationResponse, user ID:", response.id); + + console.log("Unwrapping keys..."); + const { masterKey, token } = await unwrapAuth(response, PASSWORD); + console.log("Master key length:", masterKey.length, "bytes"); + console.log("Token length:", token.length, "chars"); + + api.setAuthToken(token); + + console.log("\nFetching collections..."); + const { collections } = await api.getJSON<{ collections: unknown[] }>( + "/collections/v2", + { sinceTime: 0 }, + ); + console.log(`Got ${collections.length} collection(s)`); + for (const c of collections.slice(0, 5)) { + const col = c as Record; + console.log( + ` id=${col.id} type=${col.type} updationTime=${col.updationTime}`, + ); + } + + console.log("\nLive login test passed."); +}; + +main().catch((err) => { + console.error("FAILED:", err); + process.exit(1); +});