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.
This commit is contained in:
2026-06-10 11:46:51 -07:00
parent 59e0aa7d47
commit 15d2effc2d
6 changed files with 53 additions and 11 deletions

View File

@@ -156,7 +156,15 @@ export class Client {
collections: RawCollection[]; collections: RawCollection[];
}>("/collections/v2", { sinceTime: 0 }); }>("/collections/v2", { sinceTime: 0 });
return collections.map((raw) => return collections.map((raw) =>
decryptCollection(raw, this.masterKey, this.userID), decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
); );
} }

View File

@@ -24,6 +24,7 @@ export type {
FileBlob, FileBlob,
FileMetadata, FileMetadata,
FileType, FileType,
KeyMaterial,
Microseconds, Microseconds,
RawCollection, RawCollection,
RawEnteFile, RawEnteFile,

View File

@@ -1,10 +1,16 @@
import { decryptBlob, decryptBox, fromBase64 } from "../crypto/index.js"; import {
decryptBlob,
decryptBox,
decryptSealed,
fromBase64,
} from "../crypto/index.js";
import type { import type {
Collection, Collection,
CollectionType, CollectionType,
EnteFile, EnteFile,
FileMetadata, FileMetadata,
FileType, FileType,
KeyMaterial,
RawCollection, RawCollection,
RawEnteFile, RawEnteFile,
RawMagicMetadata, RawMagicMetadata,
@@ -30,14 +36,25 @@ const parseFileType = (n: number): FileType => FILE_TYPE_MAP[n] ?? "unknown";
export const decryptCollection = ( export const decryptCollection = (
raw: RawCollection, raw: RawCollection,
masterKey: Uint8Array, keys: KeyMaterial,
currentUserID?: number, currentUserID?: number,
): Collection => { ): Collection => {
const key = decryptBox( // Owned collections carry their key as a secretbox under our master
fromBase64(raw.encryptedKey), // key, with the nonce in keyDecryptionNonce. Collections shared with
fromBase64(raw.keyDecryptionNonce), // us carry it as an anonymous sealed box to our public key and have
masterKey, // 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 = ""; let name = "";
if (raw.encryptedName && raw.nameDecryptionNonce) { if (raw.encryptedName && raw.nameDecryptionNonce) {

View File

@@ -6,6 +6,7 @@ export type {
FileBlob, FileBlob,
FileMetadata, FileMetadata,
FileType, FileType,
KeyMaterial,
Microseconds, Microseconds,
RawCollection, RawCollection,
RawEnteFile, RawEnteFile,

View File

@@ -50,13 +50,25 @@ export interface EnteFile {
updationTime: Microseconds; 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. // Raw shapes as they arrive from the Ente API, before decryption.
export interface RawCollection { export interface RawCollection {
id: number; id: number;
owner: { id: number }; owner: { id: number };
encryptedKey: string; 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; encryptedName?: string;
nameDecryptionNonce?: string; nameDecryptionNonce?: string;
type: string; type: string;

View File

@@ -25,7 +25,10 @@ const main = async () => {
process.exit(1); process.exit(1);
} }
const { masterKey, token } = await unwrapAuth(challenge.response, PASSWORD); const { masterKey, secretKey, publicKey, token } = await unwrapAuth(
challenge.response,
PASSWORD,
);
api.setAuthToken(token); api.setAuthToken(token);
console.log("Logged in, user ID:", challenge.response.id); console.log("Logged in, user ID:", challenge.response.id);
@@ -37,7 +40,7 @@ const main = async () => {
const userID = challenge.response.id; const userID = challenge.response.id;
const collections = rawCollections.map((raw) => const collections = rawCollections.map((raw) =>
decryptCollection(raw, masterKey, userID), decryptCollection(raw, { masterKey, publicKey, secretKey }, userID),
); );
console.log(`${collections.length} collection(s):`); console.log(`${collections.length} collection(s):`);