diff --git a/src/model/decrypt.ts b/src/model/decrypt.ts new file mode 100644 index 0000000..905596c --- /dev/null +++ b/src/model/decrypt.ts @@ -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"); +}; diff --git a/src/model/index.ts b/src/model/index.ts new file mode 100644 index 0000000..7d337ed --- /dev/null +++ b/src/model/index.ts @@ -0,0 +1,12 @@ +export { decryptCollection, decryptFile } from "./decrypt.js"; +export type { + Collection, + CollectionType, + EnteFile, + FileBlob, + FileMetadata, + FileType, + Microseconds, + RawCollection, + RawEnteFile, +} from "./types.js"; diff --git a/src/model/types.ts b/src/model/types.ts new file mode 100644 index 0000000..79551a0 --- /dev/null +++ b/src/model/types.ts @@ -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; +} diff --git a/test/model/decrypt.test.ts b/test/model/decrypt.test.ts new file mode 100644 index 0000000..2e34f1e --- /dev/null +++ b/test/model/decrypt.test.ts @@ -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(); + }); +});