Files
quak/src/model/decrypt.ts
T
clawbot ead083c1d6
check / check (push) Successful in 25s
Carry file size, thumbnail size, and deletion flag through decryptFile (closes #37)
decryptFile now sets file.size/thumbnail.size from raw.info (undefined when absent) and carries an optional isDeleted on EnteFile. Plain EnteFile return, no caller changes; listFiles keeps filtering tombstones. Tests cover the three fields and the size-absent case.

Model: opus-4-8
2026-09-22 12:01:03 +02:00

148 lines
4.2 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));
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,
};
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));
};