check / check (push) Successful in 28s
downloadFile, shared by quak get, the content cache and backup, hashes the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and stores nothing on a mismatch, failing with an error naming the file ID. A live photo ZIP is unpacked as it streams with fflate's Unzip, in small slices so memory stays bounded however far an entry expands, and its image and video hashed separately as <imageHash>:<videoHash>. decryptFile reads the older imageHash/videoHash fields for live photos. A file with no recorded hash is stored unchecked. Model: opus-5-5
480 lines
19 KiB
TypeScript
480 lines
19 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 quak works with.
|
|
*
|
|
* ## Collection decryption
|
|
*
|
|
* How a collection's key is encrypted depends on who owns it:
|
|
*
|
|
* OWNED collections (owner == current user): the collection key is a
|
|
* secretbox under the owner's master key, and `keyDecryptionNonce`
|
|
* carries the nonce.
|
|
*
|
|
* SHARED collections (owned by someone else, shared with us): the owner
|
|
* does not have our master key, so the server instead carries the
|
|
* collection key as an anonymous SEALED BOX (crypto_box_seal) to our
|
|
* X25519 public key, and `keyDecryptionNonce` is ABSENT from the wire
|
|
* (sealed boxes embed an ephemeral public key instead of a nonce).
|
|
*
|
|
* `decryptCollection` therefore takes the full key material (master key
|
|
* plus keypair) and dispatches on the presence of `keyDecryptionNonce`:
|
|
*
|
|
* 1a. nonce present: decryptBox(encryptedKey, keyDecryptionNonce,
|
|
* masterKey) -> collectionKey
|
|
* 1b. nonce absent: decryptSealed(encryptedKey, publicKey, secretKey)
|
|
* -> 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 {
|
|
KeyMaterial,
|
|
RawCollection,
|
|
RawEnteFile,
|
|
} from "../../src/model/index.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The full set of key material a logged-in client holds: the master key
|
|
// (decrypts owned collection keys) and the X25519 keypair (unseals shared
|
|
// collection keys and the auth token).
|
|
const buildKeys = (): KeyMaterial => {
|
|
const kp = sodium.crypto_box_keypair();
|
|
return {
|
|
masterKey: sodium.crypto_secretbox_keygen(),
|
|
publicKey: kp.publicKey,
|
|
secretKey: kp.privateKey,
|
|
};
|
|
};
|
|
|
|
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 };
|
|
};
|
|
|
|
// A collection shared with us by another user. The wire format differs
|
|
// from owned collections in two ways, both verified against the live
|
|
// api.ente.io: `encryptedKey` is an anonymous sealed box to OUR public
|
|
// key (80 bytes for a 32-byte key, vs 48 for a secretbox), and
|
|
// `keyDecryptionNonce` is entirely ABSENT from the JSON.
|
|
const buildSharedRawCollection = (
|
|
recipientPublicKey: Uint8Array,
|
|
opts?: { name?: string; ownerID?: number },
|
|
): { raw: RawCollection; collectionKey: Uint8Array } => {
|
|
const collectionKey = sodium.crypto_secretbox_keygen();
|
|
const sealedKey = sodium.crypto_box_seal(collectionKey, recipientPublicKey);
|
|
const name = opts?.name ?? "Shared Album";
|
|
const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt(
|
|
new TextEncoder().encode(name),
|
|
collectionKey,
|
|
);
|
|
const raw: RawCollection = {
|
|
id: 101,
|
|
owner: { id: opts?.ownerID ?? 99 },
|
|
encryptedKey: toBase64(sealedKey),
|
|
// NOTE: no keyDecryptionNonce. Do not "fix" this fixture by adding
|
|
// one: its absence is exactly what the real server sends, and an
|
|
// unfaithful fixture here previously masked a crash on every
|
|
// account with an incoming shared album.
|
|
encryptedName: toBase64(encName),
|
|
nameDecryptionNonce: toBase64(nameNonce),
|
|
type: "album",
|
|
updationTime: 1700000000000000,
|
|
};
|
|
return { raw, collectionKey };
|
|
};
|
|
|
|
const buildRawFile = (
|
|
collectionKey: Uint8Array,
|
|
opts?: {
|
|
// Any JSON value; `undefined` leaves the title out of the metadata.
|
|
title?: unknown;
|
|
fileType?: number;
|
|
creationTime?: number;
|
|
info?: { fileSize?: number; thumbSize?: number };
|
|
// Replaces the whole metadata JSON value.
|
|
metadata?: unknown;
|
|
},
|
|
): RawEnteFile => {
|
|
const fileKey = sodium.crypto_secretbox_keygen();
|
|
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
|
|
fileKey,
|
|
collectionKey,
|
|
);
|
|
|
|
const defaultMetadata = {
|
|
title: opts && "title" in opts ? opts.title : "IMG_0001.jpg",
|
|
fileType: opts?.fileType ?? 0,
|
|
creationTime: opts?.creationTime ?? 1700000000000000,
|
|
modificationTime: 1700000000000000,
|
|
latitude: 48.8566,
|
|
longitude: 2.3522,
|
|
hash: "abcdef1234567890",
|
|
};
|
|
const metadata =
|
|
opts && "metadata" in opts ? opts.metadata : defaultMetadata;
|
|
// 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)) },
|
|
info: opts?.info,
|
|
updationTime: 1700000000000000,
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("model.decryptCollection", () => {
|
|
beforeAll(async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
});
|
|
|
|
it("decrypts an owned collection key and name from a raw server response", () => {
|
|
const keys = buildKeys();
|
|
const { raw, collectionKey } = buildRawCollection(keys.masterKey, {
|
|
name: "Summer Photos",
|
|
});
|
|
|
|
const col = decryptCollection(raw, keys, 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("decrypts a shared collection via sealed box when keyDecryptionNonce is absent", () => {
|
|
// A collection shared TO us is not encrypted with our master key:
|
|
// the sharer only knows our public key, so the collection key
|
|
// arrives as crypto_box_seal(collectionKey, ourPublicKey) and the
|
|
// response has NO keyDecryptionNonce. decryptCollection must
|
|
// recover the key with the keypair, then decrypt the name with it
|
|
// as usual. Accounts with any incoming shared album hit this path
|
|
// on every listCollections call.
|
|
const keys = buildKeys();
|
|
const { raw, collectionKey } = buildSharedRawCollection(
|
|
keys.publicKey,
|
|
{ name: "Friend's Wedding", ownerID: 99 },
|
|
);
|
|
|
|
const col = decryptCollection(raw, keys, 1);
|
|
|
|
expect(col.key).toEqual(collectionKey);
|
|
expect(col.name).toBe("Friend's Wedding");
|
|
expect(col.ownerID).toBe(99);
|
|
expect(col.isShared).toBe(true);
|
|
});
|
|
|
|
it("maps known type strings to CollectionType", () => {
|
|
const keys = buildKeys();
|
|
for (const type of ["album", "folder", "favorites", "uncategorized"]) {
|
|
const { raw } = buildRawCollection(keys.masterKey, { type });
|
|
const col = decryptCollection(raw, keys, 1);
|
|
expect(col.type).toBe(type);
|
|
}
|
|
});
|
|
|
|
it("maps unrecognised type strings to 'unknown'", () => {
|
|
const keys = buildKeys();
|
|
const { raw } = buildRawCollection(keys.masterKey, {
|
|
type: "someFutureType",
|
|
});
|
|
const col = decryptCollection(raw, keys, 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 keys = buildKeys();
|
|
const { raw } = buildRawCollection(keys.masterKey);
|
|
delete raw.encryptedName;
|
|
delete raw.nameDecryptionNonce;
|
|
|
|
const col = decryptCollection(raw, keys, 1);
|
|
expect(col.name).toBe("");
|
|
});
|
|
|
|
it("throws when the master key is wrong for an owned collection", () => {
|
|
const keys = buildKeys();
|
|
const { raw } = buildRawCollection(keys.masterKey);
|
|
|
|
// buildKeys() generates fresh random key material, so this is a
|
|
// client holding the wrong master key.
|
|
expect(() => decryptCollection(raw, buildKeys(), 1)).toThrow();
|
|
});
|
|
|
|
it("throws when the keypair is wrong for a shared collection", () => {
|
|
const keys = buildKeys();
|
|
const { raw } = buildSharedRawCollection(keys.publicKey);
|
|
|
|
// A different keypair must not be able to unseal the key.
|
|
const wrongKeys = buildKeys();
|
|
expect(() => decryptCollection(raw, wrongKeys, 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("reads a missing or non-string title as an empty string", () => {
|
|
// The server controls the metadata JSON. A title that is not a
|
|
// string must not reach code that builds file names from it.
|
|
const masterKey = sodium.crypto_secretbox_keygen();
|
|
const { collectionKey } = buildRawCollection(masterKey);
|
|
for (const title of [undefined, null, 42, ["a"], { x: "../y" }]) {
|
|
const file = decryptFile(
|
|
buildRawFile(collectionKey, { title }),
|
|
collectionKey,
|
|
);
|
|
expect(file.metadata.title).toBe("");
|
|
}
|
|
});
|
|
|
|
it("rejects metadata that is not a JSON object", () => {
|
|
const masterKey = sodium.crypto_secretbox_keygen();
|
|
const { collectionKey } = buildRawCollection(masterKey);
|
|
for (const metadata of [null, "IMG_0001.jpg", 7, []]) {
|
|
const raw = buildRawFile(collectionKey, { metadata });
|
|
expect(() => decryptFile(raw, collectionKey)).toThrow(
|
|
"file 200: metadata is not a JSON object",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("reads the recorded content hash, joining an older live photo's two parts", () => {
|
|
const masterKey = sodium.crypto_secretbox_keygen();
|
|
const { collectionKey } = buildRawCollection(masterKey);
|
|
const hashOf = (metadata: Record<string, unknown>) =>
|
|
decryptFile(
|
|
buildRawFile(collectionKey, {
|
|
metadata: { title: "x", ...metadata },
|
|
}),
|
|
collectionKey,
|
|
).metadata.hash;
|
|
|
|
expect(hashOf({ fileType: 0, hash: "H" })).toBe("H");
|
|
expect(
|
|
hashOf({ fileType: 2, hash: "H", imageHash: "I", videoHash: "V" }),
|
|
).toBe("H");
|
|
expect(hashOf({ fileType: 2, imageHash: "I", videoHash: "V" })).toBe(
|
|
"I:V",
|
|
);
|
|
expect(hashOf({ fileType: 2, imageHash: "I" })).toBeUndefined();
|
|
expect(
|
|
hashOf({ fileType: 0, imageHash: "I", videoHash: "V" }),
|
|
).toBeUndefined();
|
|
expect(hashOf({ fileType: 0 })).toBeUndefined();
|
|
expect(hashOf({ fileType: 0, hash: 42 })).toBeUndefined();
|
|
expect(
|
|
hashOf({ fileType: 2, imageHash: "I", videoHash: 7 }),
|
|
).toBeUndefined();
|
|
// An empty string counts as absent, not as a hash to match.
|
|
expect(hashOf({ fileType: 0, hash: "" })).toBeUndefined();
|
|
expect(
|
|
hashOf({ fileType: 2, hash: "", imageHash: "I", videoHash: "V" }),
|
|
).toBe("I:V");
|
|
expect(
|
|
hashOf({ fileType: 2, imageHash: "", videoHash: "V" }),
|
|
).toBeUndefined();
|
|
expect(
|
|
hashOf({ fileType: 2, imageHash: "I", videoHash: "" }),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it("carries the file and thumbnail byte sizes from info", () => {
|
|
// The server reports the encrypted-blob sizes in `info`; the cache
|
|
// needs them without a HEAD request, so decryptFile must copy them
|
|
// onto the file and thumbnail blobs.
|
|
const masterKey = sodium.crypto_secretbox_keygen();
|
|
const { collectionKey } = buildRawCollection(masterKey);
|
|
const raw = buildRawFile(collectionKey, {
|
|
info: { fileSize: 4096, thumbSize: 512 },
|
|
});
|
|
|
|
const file = decryptFile(raw, collectionKey);
|
|
|
|
expect(file.file.size).toBe(4096);
|
|
expect(file.thumbnail.size).toBe(512);
|
|
});
|
|
|
|
it("leaves the sizes undefined when the server omits info", () => {
|
|
// Older files predate the info field; the sizes must stay undefined
|
|
// rather than become 0, so callers can tell "unknown" from "empty".
|
|
const masterKey = sodium.crypto_secretbox_keygen();
|
|
const { collectionKey } = buildRawCollection(masterKey);
|
|
const raw = buildRawFile(collectionKey);
|
|
expect(raw.info).toBeUndefined();
|
|
|
|
const file = decryptFile(raw, collectionKey);
|
|
|
|
expect(file.file.size).toBeUndefined();
|
|
expect(file.thumbnail.size).toBeUndefined();
|
|
});
|
|
|
|
it("carries the deletion flag from the diff row", () => {
|
|
// The diff marks a deleted row with isDeleted; decryptFile copies it
|
|
// onto the file so a caller can tell a deleted row from a live one.
|
|
const masterKey = sodium.crypto_secretbox_keygen();
|
|
const { collectionKey } = buildRawCollection(masterKey);
|
|
const raw = buildRawFile(collectionKey);
|
|
raw.isDeleted = true;
|
|
|
|
const file = decryptFile(raw, collectionKey);
|
|
|
|
expect(file.isDeleted).toBe(true);
|
|
});
|
|
});
|