Skip to content
Merged
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.66.0",
"version": "2.67.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
181 changes: 170 additions & 11 deletions apps/desktop/src/main/cloud-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ afterEach(async () => {
);
});

async function setup(allowInsecureLocalDevelopment = false) {
async function setup(
allowInsecureLocalDevelopment = false,
revokeTimeoutMs?: number,
) {
const storageDirectory = await mkdtemp(
path.join(os.tmpdir(), "zennotes-cloud-auth-"),
);
Expand All @@ -44,6 +47,7 @@ async function setup(allowInsecureLocalDevelopment = false) {
randomState: () => "fixed-state",
randomVerifier: () => "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
allowInsecureLocalDevelopment,
revokeTimeoutMs,
});
return {
manager,
Expand Down Expand Up @@ -166,18 +170,10 @@ describe("CloudAuthManager", () => {

it("removes the stored credential when logging out", async () => {
const { manager, secrets, fetchImplementation } = await setup();
await manager.connect("https://zennotes.org");
await connectAccount(manager, fetchImplementation);
fetchImplementation.mockResolvedValueOnce(
new Response(
JSON.stringify({
token: "secret-token",
user: { name: "Ada", email: "ada@example.com" },
device: { id: "device-1", name: "Test Mac", platform: "desktop" },
}),
{ status: 200 },
),
new Response(null, { status: 204 }),
);
await manager.complete({ code: "OneTimeCode", state: "fixed-state" });

await expect(manager.logout()).resolves.toEqual({
state: "disconnected",
Expand Down Expand Up @@ -207,6 +203,169 @@ describe("CloudAuthManager", () => {
});
});

async function connectAccount(
manager: CloudAuthManager,
fetchImplementation: ReturnType<typeof vi.fn<typeof fetch>>,
): Promise<void> {
await manager.connect("https://zennotes.org");
fetchImplementation.mockResolvedValueOnce(
new Response(
JSON.stringify({
token: "secret-token",
user: { name: "Ada", email: "ada@example.com" },
device: { id: "device-1", name: "Test Mac", platform: "desktop" },
}),
{ status: 200 },
),
);
await manager.complete({ code: "OneTimeCode", state: "fixed-state" });
fetchImplementation.mockClear();
}

describe("CloudAuthManager logout revokes the device", () => {
it("asks the service with the token before deleting it", async () => {
const { manager, secrets, fetchImplementation, storageDirectory } =
await setup();
await connectAccount(manager, fetchImplementation);
let tokenWhileRevoking: string | undefined;
fetchImplementation.mockImplementationOnce(async () => {
tokenWhileRevoking = secrets.get("https://zennotes.org");
return new Response(null, { status: 204 });
});

await expect(manager.logout()).resolves.toEqual({
state: "disconnected",
account: null,
});

expect(fetchImplementation).toHaveBeenCalledOnce();
const [url, init] = fetchImplementation.mock.calls[0];
expect(url).toBe("https://zennotes.org/api/v1/devices/current");
expect(init).toMatchObject({
method: "DELETE",
redirect: "error",
headers: {
Accept: "application/json",
Authorization: "Bearer secret-token",
},
});
expect(init?.signal).toBeInstanceOf(AbortSignal);
expect(tokenWhileRevoking).toBe("secret-token");
expect(secrets.size).toBe(0);
await expect(
readFile(path.join(storageDirectory, "cloud-account.json"), "utf8"),
).rejects.toThrow();
});

it("signs out when the service fails or cannot be reached", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
for (const answer of [
() => Promise.reject(new TypeError("fetch failed")),
() => Promise.resolve(new Response(null, { status: 500 })),
]) {
const { manager, secrets, fetchImplementation } = await setup();
await connectAccount(manager, fetchImplementation);
fetchImplementation.mockImplementationOnce(answer);

await expect(manager.logout()).resolves.toEqual({
state: "disconnected",
account: null,
});
expect(secrets.size).toBe(0);
}
expect(warn).toHaveBeenCalledTimes(2);
} finally {
warn.mockRestore();
}
});

it("stops waiting for a service that never answers", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const { manager, secrets, fetchImplementation } = await setup(false, 20);
await connectAccount(manager, fetchImplementation);
fetchImplementation.mockImplementationOnce(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
}),
);

await expect(manager.logout()).resolves.toEqual({
state: "disconnected",
account: null,
});
expect(secrets.size).toBe(0);
expect(warn).toHaveBeenCalledOnce();
} finally {
warn.mockRestore();
}
});

it("takes an already revoked token (401) as done", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const { manager, secrets, fetchImplementation } = await setup();
await connectAccount(manager, fetchImplementation);
fetchImplementation.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Unauthenticated." }), {
status: 401,
}),
);

await manager.logout();

expect(secrets.size).toBe(0);
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});

it("asks zennotes.org for a legacy account and skips an account with no token", async () => {
const { manager, storageDirectory, secrets, fetchImplementation } =
await setup();
const legacyAccount = {
base_url: "https://zennotes.laravel.cloud",
user: { name: "Niri", email: "niri@example.com" },
device: { id: "device-3", name: "Test Mac", platform: "desktop" },
connected_at: "2026-08-12T09:00:00.000Z",
};
await writeFile(
path.join(storageDirectory, "cloud-account.json"),
JSON.stringify(legacyAccount),
);
secrets.set("https://zennotes.laravel.cloud", "legacy-token");
fetchImplementation.mockResolvedValueOnce(
new Response(null, { status: 204 }),
);

await manager.logout();

expect(fetchImplementation).toHaveBeenCalledOnce();
expect(fetchImplementation.mock.calls[0][0]).toBe(
"https://zennotes.org/api/v1/devices/current",
);
expect(fetchImplementation.mock.calls[0][1]?.headers).toMatchObject({
Authorization: "Bearer legacy-token",
});
expect(secrets.size).toBe(0);

fetchImplementation.mockClear();
await writeFile(
path.join(storageDirectory, "cloud-account.json"),
JSON.stringify({ ...legacyAccount, base_url: "https://zennotes.org" }),
);

await manager.logout();

expect(fetchImplementation).not.toHaveBeenCalled();
});
});

describe("normalizeCloudBaseUrl", () => {
it("requires a clean HTTPS origin", () => {
expect(normalizeCloudBaseUrl("https://zennotes.org/")).toBe(
Expand Down
48 changes: 47 additions & 1 deletion apps/desktop/src/main/cloud-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { CloudAuthDeepLinkRequest } from "./deep-links";
const PENDING_AUTH_FILE = "cloud-auth-pending.json";
const CLOUD_ACCOUNT_FILE = "cloud-account.json";
const AUTH_LIFETIME_MS = 5 * 60 * 1000;
const REVOKE_TIMEOUT_MS = 5_000;
const PRODUCTION_CLOUD_BASE_URL = "https://zennotes.org";
const DEVELOPMENT_CLOUD_BASE_URL = PRODUCTION_CLOUD_BASE_URL;
/** The Laravel Cloud origin 2.28.0 shipped with. The deployment moved to
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface CloudAuthManagerDependencies {
randomState?: () => string;
randomVerifier?: () => string;
allowInsecureLocalDevelopment?: boolean;
revokeTimeoutMs?: number;
}

export class CloudAuthManager {
Expand Down Expand Up @@ -197,14 +199,58 @@ export class CloudAuthManager {
async logout(): Promise<CloudAccountStatus> {
const storedAccount = await this.readJson<unknown>(CLOUD_ACCOUNT_FILE);
const account = isCloudAccount(storedAccount) ? storedAccount : null;
if (account) await this.dependencies.deleteSecret(account.base_url);
if (account) {
await this.revokeDevice(account);
await this.dependencies.deleteSecret(account.base_url);
}
await Promise.all([
this.removeFile(CLOUD_ACCOUNT_FILE),
this.removeFile(PENDING_AUTH_FILE),
]);
return { state: "disconnected", account: null };
}

/** Ask the service to revoke this device and every token it holds, as
* Connected apps on the website does, while the token still exists to ask
* with. Signing out never depends on the answer: offline, after a
* failure, or once the service has kept it waiting a few seconds, it
* signs out anyway, and the device stays listed on the website until it
* is removed there. A legacy account asks the origin its token moved to. */
private async revokeDevice(account: CloudAccount): Promise<void> {
try {
const token = await this.dependencies.getSecret(account.base_url);
if (!token) return;
const baseUrl = normalizeCloudBaseUrl(
account.base_url === LEGACY_CLOUD_BASE_URL
? PRODUCTION_CLOUD_BASE_URL
: account.base_url,
this.dependencies.allowInsecureLocalDevelopment ?? false,
);
const response = await this.fetchImplementation(
`${baseUrl}/api/v1/devices/current`,
{
method: "DELETE",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
redirect: "error",
signal: AbortSignal.timeout(
this.dependencies.revokeTimeoutMs ?? REVOKE_TIMEOUT_MS,
),
},
);
// 401 means the token is already gone, which is what was asked for.
if (!response.ok && response.status !== 401) {
console.warn(
`[cloud] sign-out could not revoke this device (HTTP ${response.status})`,
);
}
} catch (error) {
console.warn("[cloud] sign-out could not revoke this device", error);
}
}

/** Move a legacy-origin account to the production origin: rewrite the
* stored base_url and re-key the credential. The token is deleted from the
* old key only after it is safely stored under the new one; if any step
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/src/main/cloud-backup-archive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { gunzipSync, gzipSync } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
import { createHash, randomBytes } from 'node:crypto'
import type { CloudSyncContent } from '@zennotes/bridge-contract/cloud-sync'
import { generateCloudE2eeKey } from '@zennotes/shared-domain/cloud-e2ee'
import { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault'
import { rewriteBackupArchive } from './cloud-backup-archive'

const directories: string[] = []
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})

async function scratch(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-archive-'))
directories.push(directory)
return directory
}

function plain(bytes: Uint8Array, encoding: 'utf8' | 'base64', mediaType: string): CloudSyncContent {
return {
encoding,
data: encoding === 'utf8' ? Buffer.from(bytes).toString('utf8') : Buffer.from(bytes).toString('base64'),
sha256: createHash('sha256').update(bytes).digest('hex'),
byte_length: bytes.byteLength,
media_type: mediaType
}
}

describe('rewriteBackupArchive', () => {
it('turns an encrypted vault archive into a readable one, item by item', async () => {
const accountKey = generateCloudE2eeKey()
const encryption = await CloudVaultCipher.prepareVault('Private', accountKey, 1)
const cipher = await CloudVaultCipher.open({ id: 'vault-enc', encryption }, accountKey, 1)
const files = [
{ path: 'Notes/Caf\u00e9 "plans".md', kind: 'text' as const, content: plain(Buffer.from('# Plans\n\nBring {braces} and [brackets] and "quotes"\\.\n'), 'utf8', 'text/markdown') },
{ path: 'attachements/photo.png', kind: 'binary' as const, content: plain(randomBytes(200_000), 'base64', 'image/png') },
{ path: 'Empty.md', kind: 'text' as const, content: plain(Buffer.alloc(0), 'utf8', 'text/markdown') }
]
const sealedItems = []
for (const [index, file] of files.entries()) {
const { pathId, meta } = await cipher.sealMeta(file.path, file.kind, file.content)
sealedItems.push({
item_id: `item-${index}`,
path: pathId,
kind: 'binary',
revision: index + 1,
meta,
content: await cipher.sealContent(file.content)
})
}
const header = { format_version: 1, snapshot_id: 'snap-1', vault_id: 'vault-enc', cursor: 9, created_at: '2026-10-12T00:00:00+00:00' }
const archiveText = `${JSON.stringify(header).slice(0, -1)},"items":[${sealedItems.map((item) => JSON.stringify(item)).join(',')}]}`
const directory = await scratch()
const source = path.join(directory, 'sealed.json.gz')
const target = path.join(directory, 'readable.json.gz')
// Small gzip blocks force items to straddle chunk boundaries.
await writeFile(source, gzipSync(Buffer.from(archiveText), { chunkSize: 1024 }))

const result = await rewriteBackupArchive(source, target, (item) => cipher.openArchiveItem(item as never))
expect(result.items).toBe(3)
const readable = JSON.parse(gunzipSync(await readFile(target)).toString('utf8'))
expect(readable).toMatchObject(header)
expect(readable.items.map((item: { path: string }) => item.path)).toEqual(files.map((file) => file.path))
for (const [index, file] of files.entries()) {
expect(readable.items[index]).toEqual({ item_id: `item-${index}`, path: file.path, kind: file.kind, revision: index + 1, content: file.content })
}
})

it('refuses an archive that is not the service format, without writing a readable copy', async () => {
const directory = await scratch()
const source = path.join(directory, 'odd.json.gz')
const target = path.join(directory, 'out.json.gz')
await writeFile(source, gzipSync(Buffer.from('{"format_version":1,"other":[1,2],"items":[]}')))
await expect(rewriteBackupArchive(source, target, async (item) => item)).rejects.toThrow(/could not read this backup archive/)
await writeFile(source, gzipSync(Buffer.from('{"format_version":1,"items":[{"a":1}')))
await expect(rewriteBackupArchive(source, `${target}2`, async (item) => item)).rejects.toThrow(/could not read this backup archive/)
})
})
Loading
Loading