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:
@@ -3,7 +3,7 @@ import {
|
|||||||
decryptSealed,
|
decryptSealed,
|
||||||
deriveKEK,
|
deriveKEK,
|
||||||
fromBase64,
|
fromBase64,
|
||||||
toBase64URL,
|
toBase64URLPadded,
|
||||||
} from "../crypto/index.js";
|
} from "../crypto/index.js";
|
||||||
import type { AuthorizationResponse } from "./types.js";
|
import type { AuthorizationResponse } from "./types.js";
|
||||||
|
|
||||||
@@ -58,7 +58,10 @@ export const unwrapAuth = async (
|
|||||||
publicKey,
|
publicKey,
|
||||||
secretKey,
|
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 };
|
return { masterKey, secretKey, publicKey, token };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export const toBase64 = (b: Uint8Array): string =>
|
|||||||
export const toBase64URL = (b: Uint8Array): string =>
|
export const toBase64URL = (b: Uint8Array): string =>
|
||||||
sodium.to_base64(b, sodium.base64_variants.URLSAFE_NO_PADDING);
|
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)
|
// Ente uses standard base64 for most fields, URL-safe (with padding stripped)
|
||||||
// for the auth token. Rather than make callers specify, fromBase64 accepts
|
// for the auth token. Rather than make callers specify, fromBase64 accepts
|
||||||
// any of the four variants libsodium understands and returns the bytes.
|
// any of the four variants libsodium understands and returns the bytes.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export { init } from "./sodium.js";
|
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 { deriveKEK, deriveLoginSubkey } from "./kdf.js";
|
||||||
export { decryptBox, decryptSealed } from "./box.js";
|
export { decryptBox, decryptSealed } from "./box.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
* ▼ sealed_box_open(encryptedToken, publicKey, secretKey)
|
* ▼ sealed_box_open(encryptedToken, publicKey, secretKey)
|
||||||
* tokenBytes
|
* tokenBytes
|
||||||
* │
|
* │
|
||||||
* ▼ toBase64URL
|
* ▼ toBase64URLPadded
|
||||||
* token ───── X-Auth-Token header value
|
* token ───── X-Auth-Token header value
|
||||||
*
|
*
|
||||||
* No HTTP happens here. The caller is responsible for the round trip
|
* No HTTP happens here. The caller is responsible for the round trip
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
import sodium from "libsodium-wrappers-sumo";
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
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 { unwrapAuth } from "../../src/auth/unwrap.js";
|
||||||
import type {
|
import type {
|
||||||
AuthorizationResponse,
|
AuthorizationResponse,
|
||||||
@@ -154,7 +154,9 @@ describe("auth.unwrapAuth", () => {
|
|||||||
// Token is returned as URL-safe-no-padding base64 of the bytes
|
// Token is returned as URL-safe-no-padding base64 of the bytes
|
||||||
// sealed by the server. The caller passes this string directly
|
// sealed by the server. The caller passes this string directly
|
||||||
// as the X-Auth-Token header.
|
// 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 () => {
|
it("rejects a wrong password", async () => {
|
||||||
|
|||||||
64
test/integration/live-login.ts
Normal file
64
test/integration/live-login.ts
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user