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 { decryptBox, decryptSealed } from "./box.js";
export {
decryptBlob,
initStreamPull,
pullStreamChunk,
STREAM_CHUNK_OVERHEAD,

View File

@@ -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,

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 {
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,
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,
};
};