From 15d2effc2daab01dac152c05d10ae6fe43472bcd Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:46:51 -0700 Subject: [PATCH] Green: unseal shared collection keys with the account keypair decryptCollection now takes the full key material {masterKey, publicKey, secretKey} and dispatches on keyDecryptionNonce: present means an owned collection (secretbox under the master key), absent means a shared collection (sealed box to our public key). Client already held the keypair for unsealing the auth token, so it just passes it through. --- src/client.ts | 10 +++++++++- src/index.ts | 1 + src/model/decrypt.ts | 31 ++++++++++++++++++++++++------- src/model/index.ts | 1 + src/model/types.ts | 14 +++++++++++++- test/integration/live-login.ts | 7 +++++-- 6 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/client.ts b/src/client.ts index 3f145d7..b09402e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -156,7 +156,15 @@ export class Client { collections: RawCollection[]; }>("/collections/v2", { sinceTime: 0 }); return collections.map((raw) => - decryptCollection(raw, this.masterKey, this.userID), + decryptCollection( + raw, + { + masterKey: this.masterKey, + publicKey: this.publicKey, + secretKey: this.secretKey, + }, + this.userID, + ), ); } diff --git a/src/index.ts b/src/index.ts index 17c8f57..d1ae701 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export type { FileBlob, FileMetadata, FileType, + KeyMaterial, Microseconds, RawCollection, RawEnteFile, diff --git a/src/model/decrypt.ts b/src/model/decrypt.ts index 4851963..fea8f99 100644 --- a/src/model/decrypt.ts +++ b/src/model/decrypt.ts @@ -1,10 +1,16 @@ -import { decryptBlob, decryptBox, fromBase64 } from "../crypto/index.js"; +import { + decryptBlob, + decryptBox, + decryptSealed, + fromBase64, +} from "../crypto/index.js"; import type { Collection, CollectionType, EnteFile, FileMetadata, FileType, + KeyMaterial, RawCollection, RawEnteFile, RawMagicMetadata, @@ -30,14 +36,25 @@ const parseFileType = (n: number): FileType => FILE_TYPE_MAP[n] ?? "unknown"; export const decryptCollection = ( raw: RawCollection, - masterKey: Uint8Array, + keys: KeyMaterial, currentUserID?: number, ): Collection => { - const key = decryptBox( - fromBase64(raw.encryptedKey), - fromBase64(raw.keyDecryptionNonce), - masterKey, - ); + // 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) { diff --git a/src/model/index.ts b/src/model/index.ts index e86d6b1..9a7027e 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -6,6 +6,7 @@ export type { FileBlob, FileMetadata, FileType, + KeyMaterial, Microseconds, RawCollection, RawEnteFile, diff --git a/src/model/types.ts b/src/model/types.ts index 5aee6ab..ceb6c79 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -50,13 +50,25 @@ export interface EnteFile { updationTime: Microseconds; } +// The key material a logged-in client holds, everything needed to decrypt +// any collection: the master key (secretbox for owned collection keys) and +// the X25519 keypair (sealed box for collection keys shared with us). +export interface KeyMaterial { + masterKey: Uint8Array; + publicKey: Uint8Array; + secretKey: Uint8Array; +} + // Raw shapes as they arrive from the Ente API, before decryption. export interface RawCollection { id: number; owner: { id: number }; encryptedKey: string; - keyDecryptionNonce: string; + // Absent for collections shared with us: their encryptedKey is a + // sealed box to our public key, which embeds an ephemeral public key + // instead of using a nonce. + keyDecryptionNonce?: string; encryptedName?: string; nameDecryptionNonce?: string; type: string; diff --git a/test/integration/live-login.ts b/test/integration/live-login.ts index 2824583..8358ed2 100644 --- a/test/integration/live-login.ts +++ b/test/integration/live-login.ts @@ -25,7 +25,10 @@ const main = async () => { process.exit(1); } - const { masterKey, token } = await unwrapAuth(challenge.response, PASSWORD); + const { masterKey, secretKey, publicKey, token } = await unwrapAuth( + challenge.response, + PASSWORD, + ); api.setAuthToken(token); console.log("Logged in, user ID:", challenge.response.id); @@ -37,7 +40,7 @@ const main = async () => { const userID = challenge.response.id; const collections = rawCollections.map((raw) => - decryptCollection(raw, masterKey, userID), + decryptCollection(raw, { masterKey, publicKey, secretKey }, userID), ); console.log(`${collections.length} collection(s):`);