Merge: fix auth token encoding (URL-safe base64 with padding)

Live-tested against real Ente API. Login, key unwrap, and authenticated
collection listing all succeed.
This commit is contained in:
2026-05-13 17:10:11 -07:00
5 changed files with 86 additions and 6 deletions

View File

@@ -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 };
};

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 () => {

View File

@@ -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<string, unknown>;
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);
});