Fix file metadata decryption: use secretstream blob, not secretbox

File metadata is encrypted as a single-chunk secretstream blob (the
'decryptionHeader' is the secretstream init header, not a secretbox
nonce). Collection keys and names correctly use secretbox.

Adds decryptBlob(ciphertext, header, key) to the crypto module as a
convenience wrapper for single-chunk secretstream decryption (init +
pull + verify TAG_FINAL).

Live-tested: collection names and file metadata (titles, types, dates)
decrypt correctly from the real Ente API.
This commit is contained in:
2026-05-13 17:38:18 -07:00
parent f81216333e
commit 44718a92a9
5 changed files with 170 additions and 41 deletions

View File

@@ -8,6 +8,7 @@ export {
export { deriveKEK, deriveLoginSubkey } from "./kdf.js"; export { deriveKEK, deriveLoginSubkey } from "./kdf.js";
export { decryptBox, decryptSealed } from "./box.js"; export { decryptBox, decryptSealed } from "./box.js";
export { export {
decryptBlob,
initStreamPull, initStreamPull,
pullStreamChunk, pullStreamChunk,
STREAM_CHUNK_OVERHEAD, STREAM_CHUNK_OVERHEAD,

View File

@@ -23,6 +23,22 @@ export const initStreamPull = (
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream // Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the // tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the
// stream ended on TAG_FINAL to detect truncation. // 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 = ( export const pullStreamChunk = (
state: StreamPullState, state: StreamPullState,
ciphertext: Uint8Array, ciphertext: Uint8Array,

View File

@@ -1,23 +1,101 @@
// Stub: see the README "Development workflow" section for TDD policy. import { decryptBlob, decryptBox, fromBase64 } from "../crypto/index.js";
import type { import type {
Collection, Collection,
CollectionType,
EnteFile, EnteFile,
FileMetadata,
FileType,
RawCollection, RawCollection,
RawEnteFile, RawEnteFile,
} from "./types.js"; } 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 = ( export const decryptCollection = (
_raw: RawCollection, raw: RawCollection,
_masterKey: Uint8Array, masterKey: Uint8Array,
_currentUserID?: number, currentUserID?: number,
): Collection => { ): 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 = ( export const decryptFile = (
_raw: RawEnteFile, raw: RawEnteFile,
_collectionKey: Uint8Array, collectionKey: Uint8Array,
): EnteFile => { ): 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,
};
}; };

View File

@@ -2,6 +2,8 @@ import { init } from "../../src/crypto/index.js";
import { ApiClient } from "../../src/api/client.js"; import { ApiClient } from "../../src/api/client.js";
import { beginLogin } from "../../src/auth/login.js"; import { beginLogin } from "../../src/auth/login.js";
import { unwrapAuth } from "../../src/auth/unwrap.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. // Dev account — not a secret, throwaway account for integration testing.
const EMAIL = "entedev2026jp@acidhou.se"; const EMAIL = "entedev2026jp@acidhou.se";
@@ -9,7 +11,7 @@ const PASSWORD = "loldongs";
const RECOVERY_KEY = 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"; "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 () => { const main = async () => {
await init(); await init();
@@ -17,45 +19,71 @@ const main = async () => {
console.log(`Logging in as ${EMAIL}...`); console.log(`Logging in as ${EMAIL}...`);
const challenge = await beginLogin(api, EMAIL, PASSWORD); const challenge = await beginLogin(api, EMAIL, PASSWORD);
console.log("beginLogin returned:", challenge.kind); if (challenge.kind !== "complete") {
console.error("Expected complete login, got:", 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);
process.exit(1); process.exit(1);
} }
console.log("Got AuthorizationResponse, user ID:", response.id); const { masterKey, token } = await unwrapAuth(challenge.response, PASSWORD);
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");
api.setAuthToken(token); api.setAuthToken(token);
console.log("Logged in, user ID:", challenge.response.id);
// Fetch and decrypt collections
console.log("\nFetching collections..."); console.log("\nFetching collections...");
const { collections } = await api.getJSON<{ collections: unknown[] }>( const { collections: rawCollections } = await api.getJSON<{
"/collections/v2", collections: RawCollection[];
{ sinceTime: 0 }, }>("/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)) { console.log(`${collections.length} collection(s):`);
const col = c as Record<string, unknown>; for (const c of collections) {
console.log( 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) => { main().catch((err) => {

View File

@@ -99,10 +99,16 @@ const buildRawFile = (
longitude: 2.3522, longitude: 2.3522,
hash: "abcdef1234567890", 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 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, metadataBytes,
fileKey, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
); );
return { return {
@@ -113,7 +119,7 @@ const buildRawFile = (
keyDecryptionNonce: toBase64(fileKeyNonce), keyDecryptionNonce: toBase64(fileKeyNonce),
metadata: { metadata: {
encryptedData: toBase64(encMeta), encryptedData: toBase64(encMeta),
decryptionHeader: toBase64(metaNonce), decryptionHeader: toBase64(push.header),
}, },
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },