Files
quak/src/model/decrypt.ts
T
clawbot c19943a520
check / check (push) Successful in 58s
Check downloaded originals against their recorded content hash (closes #68)
downloadFile, shared by quak get, the content cache and backup, hashes
the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and stores
nothing on a mismatch, failing with an error naming the file ID. A live
photo ZIP is unpacked as it streams with fflate's Unzip, in small
slices so memory stays bounded however far an entry expands, and its
image and video hashed separately as <imageHash>:<videoHash>.
decryptFile reads the older imageHash/videoHash fields for live
photos. A file with no recorded hash is stored unchecked.

Model: opus-5-5
2026-09-23 06:16:57 +02:00

179 lines
5.5 KiB
TypeScript

import {
decryptBlob,
decryptBox,
decryptSealed,
fromBase64,
} from "../crypto/index.js";
import type {
Collection,
CollectionType,
EnteFile,
FileMetadata,
FileType,
KeyMaterial,
RawCollection,
RawEnteFile,
RawMagicMetadata,
} 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";
// The hash the uploading client recorded for the original's bytes, read the
// way the upstream client's `metadataHash` reads it: `hash` if present,
// otherwise, for a live photo from an older client that wrote the two parts
// separately, `<imageHash>:<videoHash>`. A field that is not a non-empty
// string counts as absent, and a file with no hash at all is normal.
const expectedHash = (json: Record<string, unknown>): string | undefined => {
const text = (v: unknown): string | undefined =>
typeof v === "string" && v !== "" ? v : undefined;
const hash = text(json.hash);
if (hash !== undefined) return hash;
const imageHash = text(json.imageHash);
const videoHash = text(json.videoHash);
if (
json.fileType === 2 &&
imageHash !== undefined &&
videoHash !== undefined
) {
return `${imageHash}:${videoHash}`;
}
return undefined;
};
export const decryptCollection = (
raw: RawCollection,
keys: KeyMaterial,
currentUserID?: number,
): Collection => {
// Owned collections carry their key as a secretbox under our master
// key, with the nonce in keyDecryptionNonce. Collections shared with
// us carry it as an anonymous sealed box to our public key and have
// no keyDecryptionNonce at all (sealed boxes embed an ephemeral
// public key instead).
const key = raw.keyDecryptionNonce
? decryptBox(
fromBase64(raw.encryptedKey),
fromBase64(raw.keyDecryptionNonce),
keys.masterKey,
)
: decryptSealed(
fromBase64(raw.encryptedKey),
keys.publicKey,
keys.secretKey,
);
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,
magicMetadata: decryptMagicMetadata(raw.magicMetadata, key),
pubMagicMetadata: decryptMagicMetadata(raw.pubMagicMetadata, key),
sharedMagicMetadata: decryptMagicMetadata(raw.sharedMagicMetadata, key),
};
};
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));
if (
typeof metadataJSON !== "object" ||
metadataJSON === null ||
Array.isArray(metadataJSON)
) {
throw new Error(`file ${raw.id}: metadata is not a JSON object`);
}
const metadata: FileMetadata = {
// The server controls this JSON: a title that is missing or not a
// string becomes "", never an arbitrary value.
title: typeof metadataJSON.title === "string" ? metadataJSON.title : "",
fileType: parseFileType(metadataJSON.fileType ?? -1),
creationTime: metadataJSON.creationTime ?? 0,
modificationTime: metadataJSON.modificationTime ?? 0,
latitude: metadataJSON.latitude,
longitude: metadataJSON.longitude,
hash: expectedHash(metadataJSON),
};
const magicMetadata = decryptMagicMetadata(raw.magicMetadata, key);
const pubMagicMetadata = decryptMagicMetadata(raw.pubMagicMetadata, key);
return {
id: raw.id,
collectionID: raw.collectionID,
ownerID: raw.ownerID,
key,
metadata,
magicMetadata,
pubMagicMetadata,
file: {
decryptionHeader: raw.file.decryptionHeader,
size: raw.info?.fileSize,
},
thumbnail: {
decryptionHeader: raw.thumbnail.decryptionHeader,
size: raw.info?.thumbSize,
},
updationTime: raw.updationTime,
isDeleted: raw.isDeleted,
};
};
const decryptMagicMetadata = (
raw: RawMagicMetadata | undefined,
key: Uint8Array,
): Record<string, unknown> | undefined => {
if (!raw?.data || !raw?.header) return undefined;
const bytes = decryptBlob(
fromBase64(raw.data),
fromBase64(raw.header),
key,
);
return JSON.parse(new TextDecoder().decode(bytes));
};