Phase 5 red: collection and file decryption tests
10 tests covering decryptCollection (key + name decryption from raw server JSON, type mapping, isShared, missing name, wrong key) and decryptFile (key + metadata decryption, fileType number-to-string mapping, file/thumbnail header passthrough, wrong key). Adds src/model/types.ts with both raw (server) and decrypted (library) type definitions. src/model/decrypt.ts has throwing stubs.
This commit is contained in:
23
src/model/decrypt.ts
Normal file
23
src/model/decrypt.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Stub: see the README "Development workflow" section for TDD policy.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Collection,
|
||||||
|
EnteFile,
|
||||||
|
RawCollection,
|
||||||
|
RawEnteFile,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export const decryptCollection = (
|
||||||
|
_raw: RawCollection,
|
||||||
|
_masterKey: Uint8Array,
|
||||||
|
_currentUserID?: number,
|
||||||
|
): Collection => {
|
||||||
|
throw new Error("model.decryptCollection not implemented");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptFile = (
|
||||||
|
_raw: RawEnteFile,
|
||||||
|
_collectionKey: Uint8Array,
|
||||||
|
): EnteFile => {
|
||||||
|
throw new Error("model.decryptFile not implemented");
|
||||||
|
};
|
||||||
12
src/model/index.ts
Normal file
12
src/model/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export { decryptCollection, decryptFile } from "./decrypt.js";
|
||||||
|
export type {
|
||||||
|
Collection,
|
||||||
|
CollectionType,
|
||||||
|
EnteFile,
|
||||||
|
FileBlob,
|
||||||
|
FileMetadata,
|
||||||
|
FileType,
|
||||||
|
Microseconds,
|
||||||
|
RawCollection,
|
||||||
|
RawEnteFile,
|
||||||
|
} from "./types.js";
|
||||||
74
src/model/types.ts
Normal file
74
src/model/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export type Microseconds = number;
|
||||||
|
|
||||||
|
export type CollectionType =
|
||||||
|
| "album"
|
||||||
|
| "folder"
|
||||||
|
| "favorites"
|
||||||
|
| "uncategorized"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export interface Collection {
|
||||||
|
id: number;
|
||||||
|
ownerID: number;
|
||||||
|
key: Uint8Array;
|
||||||
|
name: string;
|
||||||
|
type: CollectionType;
|
||||||
|
updationTime: Microseconds;
|
||||||
|
isShared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileType = "image" | "video" | "livePhoto" | "unknown";
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
title: string;
|
||||||
|
fileType: FileType;
|
||||||
|
creationTime: Microseconds;
|
||||||
|
modificationTime: Microseconds;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
hash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileBlob {
|
||||||
|
decryptionHeader: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnteFile {
|
||||||
|
id: number;
|
||||||
|
collectionID: number;
|
||||||
|
ownerID: number;
|
||||||
|
key: Uint8Array;
|
||||||
|
metadata: FileMetadata;
|
||||||
|
file: FileBlob;
|
||||||
|
thumbnail: FileBlob;
|
||||||
|
updationTime: Microseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw shapes as they arrive from the Ente API, before decryption.
|
||||||
|
|
||||||
|
export interface RawCollection {
|
||||||
|
id: number;
|
||||||
|
owner: { id: number };
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
encryptedName?: string;
|
||||||
|
nameDecryptionNonce?: string;
|
||||||
|
type: string;
|
||||||
|
updationTime: number;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawEnteFile {
|
||||||
|
id: number;
|
||||||
|
collectionID: number;
|
||||||
|
ownerID: number;
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
metadata: { encryptedData: string; decryptionHeader: string };
|
||||||
|
info?: { fileSize?: number; thumbSize?: number };
|
||||||
|
file: { decryptionHeader: string };
|
||||||
|
thumbnail: { decryptionHeader: string };
|
||||||
|
updationTime: number;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
267
test/model/decrypt.test.ts
Normal file
267
test/model/decrypt.test.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
};
|
||||||
|
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
|
||||||
|
const { ciphertext: encMeta, nonce: metaNonce } = secretboxEncrypt(
|
||||||
|
metadataBytes,
|
||||||
|
fileKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 200,
|
||||||
|
collectionID: 100,
|
||||||
|
ownerID: 1,
|
||||||
|
encryptedKey: toBase64(encFileKey),
|
||||||
|
keyDecryptionNonce: toBase64(fileKeyNonce),
|
||||||
|
metadata: {
|
||||||
|
encryptedData: toBase64(encMeta),
|
||||||
|
decryptionHeader: toBase64(metaNonce),
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user