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 { deriveKEK, deriveLoginSubkey } from "./kdf.js";
|
||||||
export { decryptBox, decryptSealed } from "./box.js";
|
export { decryptBox, decryptSealed } from "./box.js";
|
||||||
export {
|
export {
|
||||||
|
decryptBlob,
|
||||||
initStreamPull,
|
initStreamPull,
|
||||||
pullStreamChunk,
|
pullStreamChunk,
|
||||||
STREAM_CHUNK_OVERHEAD,
|
STREAM_CHUNK_OVERHEAD,
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ export const initStreamPull = (
|
|||||||
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
|
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
|
||||||
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the
|
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the
|
||||||
// stream ended on TAG_FINAL to detect truncation.
|
// 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 = (
|
export const pullStreamChunk = (
|
||||||
state: StreamPullState,
|
state: StreamPullState,
|
||||||
ciphertext: Uint8Array,
|
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 {
|
import type {
|
||||||
Collection,
|
Collection,
|
||||||
|
CollectionType,
|
||||||
EnteFile,
|
EnteFile,
|
||||||
|
FileMetadata,
|
||||||
|
FileType,
|
||||||
RawCollection,
|
RawCollection,
|
||||||
RawEnteFile,
|
RawEnteFile,
|
||||||
} from "./types.js";
|
} 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 = (
|
export const decryptCollection = (
|
||||||
_raw: RawCollection,
|
raw: RawCollection,
|
||||||
_masterKey: Uint8Array,
|
masterKey: Uint8Array,
|
||||||
_currentUserID?: number,
|
currentUserID?: number,
|
||||||
): Collection => {
|
): 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 = (
|
export const decryptFile = (
|
||||||
_raw: RawEnteFile,
|
raw: RawEnteFile,
|
||||||
_collectionKey: Uint8Array,
|
collectionKey: Uint8Array,
|
||||||
): EnteFile => {
|
): 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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { init } from "../../src/crypto/index.js";
|
|||||||
import { ApiClient } from "../../src/api/client.js";
|
import { ApiClient } from "../../src/api/client.js";
|
||||||
import { beginLogin } from "../../src/auth/login.js";
|
import { beginLogin } from "../../src/auth/login.js";
|
||||||
import { unwrapAuth } from "../../src/auth/unwrap.js";
|
import { unwrapAuth } from "../../src/auth/unwrap.js";
|
||||||
|
import { decryptCollection, decryptFile } from "../../src/model/index.js";
|
||||||
|
import type { RawCollection, RawEnteFile } from "../../src/model/index.js";
|
||||||
|
|
||||||
// Dev account — not a secret, throwaway account for integration testing.
|
// Dev account — not a secret, throwaway account for integration testing.
|
||||||
const EMAIL = "entedev2026jp@acidhou.se";
|
const EMAIL = "entedev2026jp@acidhou.se";
|
||||||
@@ -9,7 +11,7 @@ const PASSWORD = "loldongs";
|
|||||||
const RECOVERY_KEY =
|
const RECOVERY_KEY =
|
||||||
"deliver have behave collect void chicken boring embrace coast reflect squeeze cotton dish resemble license remain quick dwarf plastic ensure amused cry nasty equip";
|
"deliver have behave collect void chicken boring embrace coast reflect squeeze cotton dish resemble license remain quick dwarf plastic ensure amused cry nasty equip";
|
||||||
|
|
||||||
void RECOVERY_KEY; // available if needed for account recovery tests
|
void RECOVERY_KEY;
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
await init();
|
await init();
|
||||||
@@ -17,45 +19,71 @@ const main = async () => {
|
|||||||
|
|
||||||
console.log(`Logging in as ${EMAIL}...`);
|
console.log(`Logging in as ${EMAIL}...`);
|
||||||
const challenge = await beginLogin(api, EMAIL, PASSWORD);
|
const challenge = await beginLogin(api, EMAIL, PASSWORD);
|
||||||
console.log("beginLogin returned:", challenge.kind);
|
if (challenge.kind !== "complete") {
|
||||||
|
console.error("Expected complete login, got:", challenge.kind);
|
||||||
let response;
|
|
||||||
if (challenge.kind === "complete") {
|
|
||||||
response = challenge.response;
|
|
||||||
} else if (challenge.kind === "totp") {
|
|
||||||
console.error("Account requires TOTP — disable 2FA for dev testing");
|
|
||||||
process.exit(1);
|
|
||||||
} else if (challenge.kind === "emailOTP") {
|
|
||||||
console.error("Account uses email OTP — SRP not set up?");
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
console.error("Unexpected challenge:", challenge);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Got AuthorizationResponse, user ID:", response.id);
|
const { masterKey, token } = await unwrapAuth(challenge.response, PASSWORD);
|
||||||
|
|
||||||
console.log("Unwrapping keys...");
|
|
||||||
const { masterKey, token } = await unwrapAuth(response, PASSWORD);
|
|
||||||
console.log("Master key length:", masterKey.length, "bytes");
|
|
||||||
console.log("Token length:", token.length, "chars");
|
|
||||||
|
|
||||||
api.setAuthToken(token);
|
api.setAuthToken(token);
|
||||||
|
console.log("Logged in, user ID:", challenge.response.id);
|
||||||
|
|
||||||
|
// Fetch and decrypt collections
|
||||||
console.log("\nFetching collections...");
|
console.log("\nFetching collections...");
|
||||||
const { collections } = await api.getJSON<{ collections: unknown[] }>(
|
const { collections: rawCollections } = await api.getJSON<{
|
||||||
"/collections/v2",
|
collections: RawCollection[];
|
||||||
{ sinceTime: 0 },
|
}>("/collections/v2", { sinceTime: 0 });
|
||||||
|
|
||||||
|
const userID = challenge.response.id;
|
||||||
|
const collections = rawCollections.map((raw) =>
|
||||||
|
decryptCollection(raw, masterKey, userID),
|
||||||
);
|
);
|
||||||
console.log(`Got ${collections.length} collection(s)`);
|
|
||||||
for (const c of collections.slice(0, 5)) {
|
console.log(`${collections.length} collection(s):`);
|
||||||
const col = c as Record<string, unknown>;
|
for (const c of collections) {
|
||||||
console.log(
|
console.log(
|
||||||
` id=${col.id} type=${col.type} updationTime=${col.updationTime}`,
|
` [${c.type}] "${c.name}" (id=${c.id}, shared=${c.isShared})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("\nLive login test passed.");
|
// Fetch and decrypt files from the first non-empty collection
|
||||||
|
for (const col of collections) {
|
||||||
|
console.log(`\nFetching files from "${col.name}" (id=${col.id})...`);
|
||||||
|
const { diff: rawFiles } = await api.getJSON<{
|
||||||
|
diff: RawEnteFile[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}>("/collections/v2/diff", {
|
||||||
|
collectionID: col.id,
|
||||||
|
sinceTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rawFiles.length === 0) {
|
||||||
|
console.log(" (empty)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = rawFiles
|
||||||
|
.filter((f) => !f.isDeleted)
|
||||||
|
.map((raw) => decryptFile(raw, col.key));
|
||||||
|
|
||||||
|
console.log(` ${files.length} file(s):`);
|
||||||
|
for (const f of files.slice(0, 10)) {
|
||||||
|
console.log(
|
||||||
|
` ${f.metadata.title} [${f.metadata.fileType}] id=${f.id}`,
|
||||||
|
);
|
||||||
|
if (f.metadata.latitude !== undefined) {
|
||||||
|
console.log(
|
||||||
|
` location: ${f.metadata.latitude}, ${f.metadata.longitude}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length > 10) {
|
||||||
|
console.log(` ... and ${files.length - 10} more`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nLive integration test passed.");
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -99,10 +99,16 @@ const buildRawFile = (
|
|||||||
longitude: 2.3522,
|
longitude: 2.3522,
|
||||||
hash: "abcdef1234567890",
|
hash: "abcdef1234567890",
|
||||||
};
|
};
|
||||||
|
// File metadata is encrypted as a single-chunk secretstream blob
|
||||||
|
// (not secretbox). The decryptionHeader is the secretstream init header.
|
||||||
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
|
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
|
||||||
const { ciphertext: encMeta, nonce: metaNonce } = secretboxEncrypt(
|
const push =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
|
||||||
|
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
push.state,
|
||||||
metadataBytes,
|
metadataBytes,
|
||||||
fileKey,
|
null,
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -113,7 +119,7 @@ const buildRawFile = (
|
|||||||
keyDecryptionNonce: toBase64(fileKeyNonce),
|
keyDecryptionNonce: toBase64(fileKeyNonce),
|
||||||
metadata: {
|
metadata: {
|
||||||
encryptedData: toBase64(encMeta),
|
encryptedData: toBase64(encMeta),
|
||||||
decryptionHeader: toBase64(metaNonce),
|
decryptionHeader: toBase64(push.header),
|
||||||
},
|
},
|
||||||
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||||
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||||
|
|||||||
Reference in New Issue
Block a user