From e3e40229a573e3870a8471e1baac038dc3df61da Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 13 May 2026 17:10:04 -0700 Subject: [PATCH] Fix auth token encoding: use URL-safe base64 WITH padding The Ente server validates the auth token as URL-safe base64 with padding (matching Go's base64.URLEncoding). Our toBase64URL strips padding, producing a 43-char token where the server expects 44. This caused HTTP 401 'invalid token' on every authenticated call. Adds toBase64URLPadded to the crypto module and uses it in unwrapAuth for the token specifically. toBase64URL (no-padding) is kept for general use (JWT-style contexts). Adds test/integration/live-login.ts which logs into the dev account (entedev2026jp@acidhou.se), unwraps keys, and fetches collections from the real Ente API. Verified: 4 collections returned successfully. --- src/auth/unwrap.ts | 7 ++-- src/crypto/encoding.ts | 6 ++++ src/crypto/index.ts | 7 +++- test/auth/unwrap.test.ts | 8 +++-- test/integration/live-login.ts | 64 ++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 test/integration/live-login.ts 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); +});