diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 6cff5f5..a8c6658 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -8,6 +8,7 @@ export { export { deriveKEK, deriveLoginSubkey } from "./kdf.js"; export { decryptBox, decryptSealed } from "./box.js"; export { + decryptBlob, initStreamPull, pullStreamChunk, STREAM_CHUNK_OVERHEAD, diff --git a/src/crypto/stream.ts b/src/crypto/stream.ts index 1197f9c..f0c4757 100644 --- a/src/crypto/stream.ts +++ b/src/crypto/stream.ts @@ -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, diff --git a/src/model/decrypt.ts b/src/model/decrypt.ts index 905596c..dd9f7b5 100644 --- a/src/model/decrypt.ts +++ b/src/model/decrypt.ts @@ -1,23 +1,101 @@ -// Stub: see the README "Development workflow" section for TDD policy. - +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 = { + 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, + raw: RawCollection, + masterKey: Uint8Array, + currentUserID?: number, ): Collection => { - throw new Error("model.decryptCollection not implemented"); + 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, + raw: RawEnteFile, + collectionKey: Uint8Array, ): EnteFile => { - throw new Error("model.decryptFile not implemented"); + 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, + }; }; diff --git a/test/integration/live-login.ts b/test/integration/live-login.ts index 5780f84..5258d45 100644 --- a/test/integration/live-login.ts +++ b/test/integration/live-login.ts @@ -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; + + 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) => { diff --git a/test/model/decrypt.test.ts b/test/model/decrypt.test.ts index 2e34f1e..1686b0c 100644 --- a/test/model/decrypt.test.ts +++ b/test/model/decrypt.test.ts @@ -99,10 +99,16 @@ const buildRawFile = ( 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 { ciphertext: encMeta, nonce: metaNonce } = secretboxEncrypt( + const push = + sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey); + const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( + push.state, metadataBytes, - fileKey, + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); return { @@ -113,7 +119,7 @@ const buildRawFile = ( keyDecryptionNonce: toBase64(fileKeyNonce), metadata: { encryptedData: toBase64(encMeta), - decryptionHeader: toBase64(metaNonce), + decryptionHeader: toBase64(push.header), }, file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },