Files
quak/test/model/decrypt.test.ts
sneak 44718a92a9 Fix file metadata decryption: use secretstream blob, not secretbox
File metadata is encrypted as a single-chunk secretstream blob (the
'decryptionHeader' is the secretstream init header, not a secretbox
nonce). Collection keys and names correctly use secretbox.

Adds decryptBlob(ciphertext, header, key) to the crypto module as a
convenience wrapper for single-chunk secretstream decryption (init +
pull + verify TAG_FINAL).

Live-tested: collection names and file metadata (titles, types, dates)
decrypt correctly from the real Ente API.
2026-05-13 17:38:18 -07:00

274 lines
10 KiB
TypeScript

/**
* Tests for `model.decryptCollection` and `model.decryptFile`.
*
* These two functions turn the raw encrypted JSON blobs the Ente server
* returns into the decrypted Collection and EnteFile objects that the
* rest of quack works with.
*
* ## Collection decryption
*
* The server stores each collection's encryption key sealed under the
* owner's master key (secretbox). The collection's name is then sealed
* under that collection key (also secretbox). `decryptCollection`:
*
* 1. decryptBox(encryptedKey, keyDecryptionNonce, masterKey) -> collectionKey
* 2. decryptBox(encryptedName, nameDecryptionNonce, collectionKey) -> name (UTF-8)
* 3. Maps the string `type` field to a CollectionType union member
* 4. Returns a Collection with decrypted key, name, and type
*
* If the collection is shared (owner != current user), `isShared` is true.
*
* ## File decryption
*
* Each file has its own encryption key, sealed under the collection key
* (secretbox). File metadata (title, creation time, geolocation, etc.)
* is a JSON blob sealed under the file key (also secretbox, where the
* field called `decryptionHeader` is the nonce). `decryptFile`:
*
* 1. decryptBox(encryptedKey, keyDecryptionNonce, collectionKey) -> fileKey
* 2. decryptBox(metadata.encryptedData, metadata.decryptionHeader, fileKey) -> JSON
* 3. Parses the JSON into a typed FileMetadata
* 4. Returns an EnteFile with decrypted key, metadata, and the raw
* file/thumbnail decryption headers (used later for download)
*
* The tests build synthetic encrypted payloads using libsodium directly.
*/
import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js";
import { decryptCollection, decryptFile } from "../../src/model/index.js";
import type { RawCollection, RawEnteFile } from "../../src/model/index.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const secretboxEncrypt = (
plaintext: Uint8Array,
key: Uint8Array,
): { ciphertext: Uint8Array; nonce: Uint8Array } => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return { ciphertext, nonce };
};
const buildRawCollection = (
masterKey: Uint8Array,
opts?: { name?: string; type?: string; ownerID?: number },
): { raw: RawCollection; collectionKey: Uint8Array } => {
const collectionKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encKey, nonce: keyNonce } = secretboxEncrypt(
collectionKey,
masterKey,
);
const name = opts?.name ?? "Vacation 2025";
const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt(
new TextEncoder().encode(name),
collectionKey,
);
const raw: RawCollection = {
id: 100,
owner: { id: opts?.ownerID ?? 1 },
encryptedKey: toBase64(encKey),
keyDecryptionNonce: toBase64(keyNonce),
encryptedName: toBase64(encName),
nameDecryptionNonce: toBase64(nameNonce),
type: opts?.type ?? "album",
updationTime: 1700000000000000,
};
return { raw, collectionKey };
};
const buildRawFile = (
collectionKey: Uint8Array,
opts?: { title?: string; fileType?: number; creationTime?: number },
): RawEnteFile => {
const fileKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
fileKey,
collectionKey,
);
const metadata = {
title: opts?.title ?? "IMG_0001.jpg",
fileType: opts?.fileType ?? 0,
creationTime: opts?.creationTime ?? 1700000000000000,
modificationTime: 1700000000000000,
latitude: 48.8566,
longitude: 2.3522,
hash: "abcdef1234567890",
};
// File metadata is encrypted as a single-chunk secretstream blob
// (not secretbox). The decryptionHeader is the secretstream init header.
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
const push =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
metadataBytes,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
return {
id: 200,
collectionID: 100,
ownerID: 1,
encryptedKey: toBase64(encFileKey),
keyDecryptionNonce: toBase64(fileKeyNonce),
metadata: {
encryptedData: toBase64(encMeta),
decryptionHeader: toBase64(push.header),
},
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
updationTime: 1700000000000000,
};
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("model.decryptCollection", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
it("decrypts the collection key and name from a raw server response", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const { raw, collectionKey } = buildRawCollection(masterKey, {
name: "Summer Photos",
});
const col = decryptCollection(raw, masterKey, 1);
expect(col.id).toBe(100);
expect(col.key).toEqual(collectionKey);
expect(col.name).toBe("Summer Photos");
expect(col.type).toBe("album");
expect(col.ownerID).toBe(1);
expect(col.updationTime).toBe(1700000000000000);
expect(col.isShared).toBe(false);
});
it("sets isShared when owner != current user", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const { raw } = buildRawCollection(masterKey, { ownerID: 99 });
const col = decryptCollection(raw, masterKey, 1);
expect(col.isShared).toBe(true);
});
it("maps known type strings to CollectionType", () => {
const masterKey = sodium.crypto_secretbox_keygen();
for (const type of ["album", "folder", "favorites", "uncategorized"]) {
const { raw } = buildRawCollection(masterKey, { type });
const col = decryptCollection(raw, masterKey, 1);
expect(col.type).toBe(type);
}
});
it("maps unrecognised type strings to 'unknown'", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const { raw } = buildRawCollection(masterKey, {
type: "someFutureType",
});
const col = decryptCollection(raw, masterKey, 1);
expect(col.type).toBe("unknown");
});
it("handles a collection with no encrypted name gracefully", () => {
// Some special collections (e.g. uncategorized) may have no name.
const masterKey = sodium.crypto_secretbox_keygen();
const { raw } = buildRawCollection(masterKey);
delete raw.encryptedName;
delete raw.nameDecryptionNonce;
const col = decryptCollection(raw, masterKey, 1);
expect(col.name).toBe("");
});
it("throws when the master key is wrong", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const wrongKey = sodium.crypto_secretbox_keygen();
const { raw } = buildRawCollection(masterKey);
expect(() => decryptCollection(raw, wrongKey, 1)).toThrow();
});
});
describe("model.decryptFile", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
it("decrypts the file key and metadata from a raw server response", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
const raw = buildRawFile(collectionKey, {
title: "sunset.heic",
fileType: 0,
creationTime: 1710000000000000,
});
const file = decryptFile(raw, collectionKey);
expect(file.id).toBe(200);
expect(file.collectionID).toBe(100);
expect(file.ownerID).toBe(1);
expect(file.key.length).toBe(32);
expect(file.metadata.title).toBe("sunset.heic");
expect(file.metadata.fileType).toBe("image");
expect(file.metadata.creationTime).toBe(1710000000000000);
expect(file.metadata.latitude).toBeCloseTo(48.8566);
expect(file.metadata.longitude).toBeCloseTo(2.3522);
});
it("maps fileType numbers to FileType strings", () => {
// Ente uses: 0=image, 1=video, 2=livePhoto
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
const mapping: [number, string][] = [
[0, "image"],
[1, "video"],
[2, "livePhoto"],
[99, "unknown"],
];
for (const [num, expected] of mapping) {
const raw = buildRawFile(collectionKey, { fileType: num });
const file = decryptFile(raw, collectionKey);
expect(file.metadata.fileType).toBe(expected);
}
});
it("preserves the file and thumbnail decryption headers", () => {
// These headers are passed to initStreamPull later when
// downloading and decrypting the actual file content. The
// decryptFile function must pass them through unchanged.
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
const raw = buildRawFile(collectionKey);
const file = decryptFile(raw, collectionKey);
expect(file.file.decryptionHeader).toBe(raw.file.decryptionHeader);
expect(file.thumbnail.decryptionHeader).toBe(
raw.thumbnail.decryptionHeader,
);
});
it("throws when the collection key is wrong", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
const wrongKey = sodium.crypto_secretbox_keygen();
const raw = buildRawFile(collectionKey);
expect(() => decryptFile(raw, wrongKey)).toThrow();
});
});