check / check (push) Successful in 30s
A file title or album name decrypted from server data could name a path outside the chosen directory (`../../.ssh/authorized_keys`). One module, src/filename.ts, now makes such names safe for `quak get`/`get-thumb` without `--out`, downloadFile/downloadThumbnail without outPath, and the backup and metadata backup trees. Originals-cache extensions are limited to letters and digits. A user-supplied path is still used as is. decryptFile reads a missing or non-string title as "" and rejects metadata that is not a JSON object. Model: opus-5-5
440 lines
17 KiB
TypeScript
440 lines
17 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("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);
|
|
});
|
|
});
|