Merge: Phase 5 collection and file decryption
decryptCollection: secretbox key+name from raw server JSON, type mapping, isShared. decryptFile: secretbox key + secretstream-blob metadata, fileType mapping, header passthrough. decryptBlob: convenience for single-chunk secretstream (file metadata uses this form, not secretbox). 10 unit tests + live integration test against real Ente API. All 74 pass.
This commit is contained in:
16
README.md
16
README.md
@@ -629,12 +629,16 @@ Phase 4: HTTP client + endpoints
|
||||
|
||||
Phase 5: collections and files
|
||||
|
||||
- [ ] `listCollections()` paginating on `sinceTime` until empty
|
||||
- [ ] Decrypt per-collection key with master key
|
||||
- [ ] Decrypt collection name with collection key
|
||||
- [ ] `listFiles(collectionID)` paginating on `sinceTime` while `hasMore`
|
||||
- [ ] Decrypt per-file key with collection key
|
||||
- [ ] Decrypt file metadata blob with file key, expose typed `FileMetadata`
|
||||
- [x] `decryptCollection(raw, masterKey)` with key + name decryption, type
|
||||
mapping, isShared flag
|
||||
- [x] `decryptFile(raw, collectionKey)` with key + metadata decryption
|
||||
(secretstream blob, not secretbox), fileType mapping, header passthrough
|
||||
- [x] `decryptBlob(ciphertext, header, key)` convenience for single-chunk
|
||||
secretstream decryption (used by file metadata and magic metadata)
|
||||
- [x] Model types: `Collection`, `EnteFile`, `FileMetadata`, `RawCollection`,
|
||||
`RawEnteFile`
|
||||
- [x] Live-tested against real Ente API (collection names + file metadata)
|
||||
- [ ] Higher-level `listCollections()` / `listFiles()` with pagination
|
||||
|
||||
Phase 6: download
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
export { deriveKEK, deriveLoginSubkey } from "./kdf.js";
|
||||
export { decryptBox, decryptSealed } from "./box.js";
|
||||
export {
|
||||
decryptBlob,
|
||||
initStreamPull,
|
||||
pullStreamChunk,
|
||||
STREAM_CHUNK_OVERHEAD,
|
||||
|
||||
@@ -23,6 +23,22 @@ export const initStreamPull = (
|
||||
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
|
||||
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the
|
||||
// stream ended on TAG_FINAL to detect truncation.
|
||||
// Decrypt a small blob that was encrypted as a single secretstream chunk
|
||||
// with TAG_FINAL. Ente uses this form ("blob") for file metadata and
|
||||
// magic metadata — anything under ~1 MiB that isn't chunked.
|
||||
export const decryptBlob = (
|
||||
ciphertext: Uint8Array,
|
||||
header: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Uint8Array => {
|
||||
const state = initStreamPull(header, key);
|
||||
const { plaintext, tag } = pullStreamChunk(state, ciphertext);
|
||||
if (tag !== sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
|
||||
throw new Error(`decryptBlob: expected TAG_FINAL (3), got tag ${tag}`);
|
||||
}
|
||||
return plaintext;
|
||||
};
|
||||
|
||||
export const pullStreamChunk = (
|
||||
state: StreamPullState,
|
||||
ciphertext: Uint8Array,
|
||||
|
||||
101
src/model/decrypt.ts
Normal file
101
src/model/decrypt.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { decryptBlob, decryptBox, fromBase64 } from "../crypto/index.js";
|
||||
import type {
|
||||
Collection,
|
||||
CollectionType,
|
||||
EnteFile,
|
||||
FileMetadata,
|
||||
FileType,
|
||||
RawCollection,
|
||||
RawEnteFile,
|
||||
} from "./types.js";
|
||||
|
||||
const KNOWN_COLLECTION_TYPES = new Set([
|
||||
"album",
|
||||
"folder",
|
||||
"favorites",
|
||||
"uncategorized",
|
||||
]);
|
||||
|
||||
const parseCollectionType = (s: string): CollectionType =>
|
||||
KNOWN_COLLECTION_TYPES.has(s) ? (s as CollectionType) : "unknown";
|
||||
|
||||
const FILE_TYPE_MAP: Record<number, FileType> = {
|
||||
0: "image",
|
||||
1: "video",
|
||||
2: "livePhoto",
|
||||
};
|
||||
|
||||
const parseFileType = (n: number): FileType => FILE_TYPE_MAP[n] ?? "unknown";
|
||||
|
||||
export const decryptCollection = (
|
||||
raw: RawCollection,
|
||||
masterKey: Uint8Array,
|
||||
currentUserID?: number,
|
||||
): Collection => {
|
||||
const key = decryptBox(
|
||||
fromBase64(raw.encryptedKey),
|
||||
fromBase64(raw.keyDecryptionNonce),
|
||||
masterKey,
|
||||
);
|
||||
|
||||
let name = "";
|
||||
if (raw.encryptedName && raw.nameDecryptionNonce) {
|
||||
const nameBytes = decryptBox(
|
||||
fromBase64(raw.encryptedName),
|
||||
fromBase64(raw.nameDecryptionNonce),
|
||||
key,
|
||||
);
|
||||
name = new TextDecoder().decode(nameBytes);
|
||||
}
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
ownerID: raw.owner.id,
|
||||
key,
|
||||
name,
|
||||
type: parseCollectionType(raw.type),
|
||||
updationTime: raw.updationTime,
|
||||
isShared: currentUserID !== undefined && raw.owner.id !== currentUserID,
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptFile = (
|
||||
raw: RawEnteFile,
|
||||
collectionKey: Uint8Array,
|
||||
): EnteFile => {
|
||||
const key = decryptBox(
|
||||
fromBase64(raw.encryptedKey),
|
||||
fromBase64(raw.keyDecryptionNonce),
|
||||
collectionKey,
|
||||
);
|
||||
|
||||
// File metadata is a single-chunk secretstream blob (not a secretbox).
|
||||
// The decryptionHeader field is the secretstream init header, not a nonce.
|
||||
const metadataBytes = decryptBlob(
|
||||
fromBase64(raw.metadata.encryptedData),
|
||||
fromBase64(raw.metadata.decryptionHeader),
|
||||
key,
|
||||
);
|
||||
const metadataJSON = JSON.parse(new TextDecoder().decode(metadataBytes));
|
||||
|
||||
const metadata: FileMetadata = {
|
||||
title: metadataJSON.title ?? "",
|
||||
fileType: parseFileType(metadataJSON.fileType ?? -1),
|
||||
creationTime: metadataJSON.creationTime ?? 0,
|
||||
modificationTime: metadataJSON.modificationTime ?? 0,
|
||||
latitude: metadataJSON.latitude,
|
||||
longitude: metadataJSON.longitude,
|
||||
hash: metadataJSON.hash,
|
||||
};
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
collectionID: raw.collectionID,
|
||||
ownerID: raw.ownerID,
|
||||
key,
|
||||
metadata,
|
||||
file: { decryptionHeader: raw.file.decryptionHeader },
|
||||
thumbnail: { decryptionHeader: raw.thumbnail.decryptionHeader },
|
||||
updationTime: raw.updationTime,
|
||||
};
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { init } from "../../src/crypto/index.js";
|
||||
import { ApiClient } from "../../src/api/client.js";
|
||||
import { beginLogin } from "../../src/auth/login.js";
|
||||
import { unwrapAuth } from "../../src/auth/unwrap.js";
|
||||
import { decryptCollection, decryptFile } from "../../src/model/index.js";
|
||||
import type { RawCollection, RawEnteFile } from "../../src/model/index.js";
|
||||
|
||||
// Dev account — not a secret, throwaway account for integration testing.
|
||||
const EMAIL = "entedev2026jp@acidhou.se";
|
||||
@@ -9,7 +11,7 @@ const PASSWORD = "loldongs";
|
||||
const RECOVERY_KEY =
|
||||
"deliver have behave collect void chicken boring embrace coast reflect squeeze cotton dish resemble license remain quick dwarf plastic ensure amused cry nasty equip";
|
||||
|
||||
void RECOVERY_KEY; // available if needed for account recovery tests
|
||||
void RECOVERY_KEY;
|
||||
|
||||
const main = async () => {
|
||||
await init();
|
||||
@@ -17,45 +19,71 @@ const main = async () => {
|
||||
|
||||
console.log(`Logging in as ${EMAIL}...`);
|
||||
const challenge = await beginLogin(api, EMAIL, PASSWORD);
|
||||
console.log("beginLogin returned:", challenge.kind);
|
||||
|
||||
let response;
|
||||
if (challenge.kind === "complete") {
|
||||
response = challenge.response;
|
||||
} else if (challenge.kind === "totp") {
|
||||
console.error("Account requires TOTP — disable 2FA for dev testing");
|
||||
process.exit(1);
|
||||
} else if (challenge.kind === "emailOTP") {
|
||||
console.error("Account uses email OTP — SRP not set up?");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error("Unexpected challenge:", challenge);
|
||||
if (challenge.kind !== "complete") {
|
||||
console.error("Expected complete login, got:", challenge.kind);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Got AuthorizationResponse, user ID:", response.id);
|
||||
|
||||
console.log("Unwrapping keys...");
|
||||
const { masterKey, token } = await unwrapAuth(response, PASSWORD);
|
||||
console.log("Master key length:", masterKey.length, "bytes");
|
||||
console.log("Token length:", token.length, "chars");
|
||||
|
||||
const { masterKey, token } = await unwrapAuth(challenge.response, PASSWORD);
|
||||
api.setAuthToken(token);
|
||||
console.log("Logged in, user ID:", challenge.response.id);
|
||||
|
||||
// Fetch and decrypt collections
|
||||
console.log("\nFetching collections...");
|
||||
const { collections } = await api.getJSON<{ collections: unknown[] }>(
|
||||
"/collections/v2",
|
||||
{ sinceTime: 0 },
|
||||
const { collections: rawCollections } = await api.getJSON<{
|
||||
collections: RawCollection[];
|
||||
}>("/collections/v2", { sinceTime: 0 });
|
||||
|
||||
const userID = challenge.response.id;
|
||||
const collections = rawCollections.map((raw) =>
|
||||
decryptCollection(raw, masterKey, userID),
|
||||
);
|
||||
console.log(`Got ${collections.length} collection(s)`);
|
||||
for (const c of collections.slice(0, 5)) {
|
||||
const col = c as Record<string, unknown>;
|
||||
|
||||
console.log(`${collections.length} collection(s):`);
|
||||
for (const c of collections) {
|
||||
console.log(
|
||||
` id=${col.id} type=${col.type} updationTime=${col.updationTime}`,
|
||||
` [${c.type}] "${c.name}" (id=${c.id}, shared=${c.isShared})`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nLive login test passed.");
|
||||
// Fetch and decrypt files from the first non-empty collection
|
||||
for (const col of collections) {
|
||||
console.log(`\nFetching files from "${col.name}" (id=${col.id})...`);
|
||||
const { diff: rawFiles } = await api.getJSON<{
|
||||
diff: RawEnteFile[];
|
||||
hasMore: boolean;
|
||||
}>("/collections/v2/diff", {
|
||||
collectionID: col.id,
|
||||
sinceTime: 0,
|
||||
});
|
||||
|
||||
if (rawFiles.length === 0) {
|
||||
console.log(" (empty)");
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = rawFiles
|
||||
.filter((f) => !f.isDeleted)
|
||||
.map((raw) => decryptFile(raw, col.key));
|
||||
|
||||
console.log(` ${files.length} file(s):`);
|
||||
for (const f of files.slice(0, 10)) {
|
||||
console.log(
|
||||
` ${f.metadata.title} [${f.metadata.fileType}] id=${f.id}`,
|
||||
);
|
||||
if (f.metadata.latitude !== undefined) {
|
||||
console.log(
|
||||
` location: ${f.metadata.latitude}, ${f.metadata.longitude}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (files.length > 10) {
|
||||
console.log(` ... and ${files.length - 10} more`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("\nLive integration test passed.");
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
273
test/model/decrypt.test.ts
Normal file
273
test/model/decrypt.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user