Files
quak/src/model/decrypt.ts
T
clawbot 3871d6228e
check / check (push) Successful in 37s
Sanitize file names taken from server metadata (closes #9)
A file title or album name decrypted from server data could name a path
outside the chosen directory (`../../.ssh/authorized_keys`). One module,
src/filename.ts, now makes such names safe for `quak get`/`get-thumb`
without `--out`, downloadFile/downloadThumbnail without outPath, and the
backup and metadata backup trees. Originals-cache extensions are limited to
letters and digits. A user-supplied path is still used as is. decryptFile
reads a missing or non-string title as "" and rejects metadata that is not
a JSON object.

Model: opus-5-5
2026-09-23 02:04:31 +02:00

157 lines
4.6 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";
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: metadataJSON.hash,
};
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));
};