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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user