Validate session snapshots and wipe keys on logout (closes #10)
check / check (push) Successful in 17s
check / check (push) Successful in 17s
Client.fromJSON checks every snapshot field and each key's decoded length and throws an error naming the bad field. toJSON reads the token through a new ApiClient.getAuthToken and throws when there is none. logout zeroes the key buffers in place; collectionsSince re-checks for logout after its request so it never decrypts with zeroed keys. The CLI now reports a corrupt session file separately from a missing one. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Tests for the client session lifecycle: `toJSON`, `fromJSON`, `logout`, and
|
||||
* the CLI's `loadSession`, which reads the saved session file back into a
|
||||
* client.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { init, toBase64 } from "../../src/crypto/index.js";
|
||||
import { Client, type ClientSnapshot } from "../../src/client.js";
|
||||
import { loadSession } from "../../src/cli-session.js";
|
||||
|
||||
const validSnapshot = (): ClientSnapshot => {
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
return {
|
||||
email: "user@example.com",
|
||||
userID: 42,
|
||||
token: "test-token",
|
||||
masterKey: toBase64(sodium.crypto_secretbox_keygen()),
|
||||
secretKey: toBase64(kp.privateKey),
|
||||
publicKey: toBase64(kp.publicKey),
|
||||
};
|
||||
};
|
||||
|
||||
// The client's key buffers are private; the tests read them to prove that
|
||||
// logout wipes them.
|
||||
const keyBuffers = (client: Client): Uint8Array[] => [
|
||||
client["masterKey"],
|
||||
client["secretKey"],
|
||||
client["publicKey"],
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
});
|
||||
|
||||
describe("Client.toJSON", () => {
|
||||
it("round-trips through fromJSON unchanged", () => {
|
||||
const snapshot = validSnapshot();
|
||||
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("throws instead of emitting a snapshot without a token", () => {
|
||||
const client = Client.fromJSON(validSnapshot());
|
||||
client.getApiClient().clearAuthToken();
|
||||
expect(() => client.toJSON()).toThrow(/no auth token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client.fromJSON", () => {
|
||||
const shortKey = toBase64(new Uint8Array(16));
|
||||
|
||||
it.each([
|
||||
["email", undefined],
|
||||
["email", 7],
|
||||
["email", ""],
|
||||
["token", undefined],
|
||||
["token", null],
|
||||
["token", ""],
|
||||
["userID", undefined],
|
||||
["userID", "42"],
|
||||
["userID", 4.2],
|
||||
["masterKey", undefined],
|
||||
["masterKey", 7],
|
||||
["masterKey", "not base64!"],
|
||||
["masterKey", shortKey],
|
||||
["secretKey", undefined],
|
||||
["secretKey", "not base64!"],
|
||||
["secretKey", shortKey],
|
||||
["publicKey", undefined],
|
||||
["publicKey", "not base64!"],
|
||||
["publicKey", shortKey],
|
||||
])("rejects %s = %j, naming the field", (field, value) => {
|
||||
const snapshot: Record<string, unknown> = { ...validSnapshot() };
|
||||
snapshot[field] = value;
|
||||
expect(() => Client.fromJSON(snapshot)).toThrow(
|
||||
new RegExp(`^Invalid session data: ${field} `),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
|
||||
expect(() => Client.fromJSON(value)).toThrow(
|
||||
/^Invalid session data: not a JSON object/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client.logout", () => {
|
||||
it("zeroes the key buffers and clears the token", () => {
|
||||
const client = Client.fromJSON(validSnapshot());
|
||||
const api = client.getApiClient();
|
||||
const keys = keyBuffers(client);
|
||||
|
||||
client.logout();
|
||||
|
||||
for (const key of keys) {
|
||||
expect(key.length).toBe(32);
|
||||
expect(key.every((b) => b === 0)).toBe(true);
|
||||
}
|
||||
expect(api.getAuthToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("makes every later operation throw", async () => {
|
||||
const client = Client.fromJSON(validSnapshot());
|
||||
client.logout();
|
||||
|
||||
expect(() => client.whoami()).toThrow(/logged out/);
|
||||
expect(() => client.toJSON()).toThrow(/logged out/);
|
||||
expect(() => client.getApiClient()).toThrow(/logged out/);
|
||||
expect(() => client.contentSource()).toThrow(/logged out/);
|
||||
await expect(client.listCollections()).rejects.toThrow(/logged out/);
|
||||
await expect(client.collectionsSince({ sinceTime: 0 })).rejects.toThrow(
|
||||
/logged out/,
|
||||
);
|
||||
await expect(
|
||||
client.filesSince({
|
||||
collectionID: 1,
|
||||
collectionKey: new Uint8Array(32),
|
||||
sinceTime: 0,
|
||||
}),
|
||||
).rejects.toThrow(/logged out/);
|
||||
await expect(
|
||||
client.fetchMLData({ fileIDs: [1], fileKeys: new Map() }),
|
||||
).rejects.toThrow(/logged out/);
|
||||
});
|
||||
|
||||
it("stops a listing in flight from decrypting with the zeroed keys", async () => {
|
||||
// The server answers only after the client has logged out. If the
|
||||
// listing went on to decrypt this row with all-zero keys it would fail
|
||||
// with a decryption error, not the logged-out one.
|
||||
const row = {
|
||||
id: 1,
|
||||
owner: { id: 42 },
|
||||
encryptedKey: toBase64(new Uint8Array(48)),
|
||||
keyDecryptionNonce: toBase64(new Uint8Array(24)),
|
||||
updationTime: 1,
|
||||
};
|
||||
const client: Client = Client.fromJSON(validSnapshot(), {
|
||||
fetch: async () => {
|
||||
client.logout();
|
||||
return new Response(JSON.stringify({ collections: [row] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client.listCollections()).rejects.toThrow(/logged out/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSession", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "quak-session-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns null when there is no session file", () => {
|
||||
expect(loadSession(join(dir, "missing.json"))).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a client from a valid session file", () => {
|
||||
const path = join(dir, "valid.json");
|
||||
writeFileSync(path, JSON.stringify(validSnapshot()));
|
||||
expect(loadSession(path)!.whoami()).toEqual({
|
||||
email: "user@example.com",
|
||||
userID: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("says the file is corrupt when it is not JSON", () => {
|
||||
const path = join(dir, "truncated.json");
|
||||
writeFileSync(path, '{"email": "user@exa');
|
||||
expect(() => loadSession(path)).toThrow(
|
||||
`Session file ${path} is corrupt`,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the file is corrupt and names the bad field", () => {
|
||||
const path = join(dir, "bad-key.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ ...validSnapshot(), secretKey: "AAAA" }),
|
||||
);
|
||||
expect(() => loadSession(path)).toThrow(
|
||||
new RegExp(`^Session file ${path} is corrupt: .*secretKey`),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user