Red: shared collections arrive as sealed boxes with no keyDecryptionNonce

Collections shared with the account are not encrypted with the master
key: the sharer only knows the recipient's public key, so the server
delivers encryptedKey as crypto_box_seal to that key and omits
keyDecryptionNonce entirely. decryptCollection assumed the owned-only
wire format and crashed on fromBase64(undefined) for any account with
an incoming shared album, taking down listCollections and every
command built on it (backup, backup-metadata, ...).

The previous "shared" fixture was unfaithful (secretbox + nonce with a
foreign ownerID, a shape the server never sends), which is why the
suite stayed green. These tests model the real wire format and change
decryptCollection to take the full key material {masterKey, publicKey,
secretKey} so it can unseal shared collection keys.
This commit is contained in:
2026-06-10 11:41:36 -07:00
parent b86ac2cd20
commit 59e0aa7d47
2 changed files with 174 additions and 37 deletions

View File

@@ -91,6 +91,7 @@ interface ServerState {
thumbHeader: Uint8Array;
thumbCiphertext: Uint8Array;
collectionKey: Uint8Array;
sharedCollectionKey: Uint8Array;
}
let server: ServerState;
@@ -211,6 +212,35 @@ const buildServer = async (): Promise<ServerState> => {
updationTime: 1700000000000000,
};
// A collection another user shared WITH us. The sharer does not have
// our master key, only our public key, so the real server delivers the
// collection key as an anonymous sealed box (crypto_box_seal) to our
// public key and the response carries NO keyDecryptionNonce. Every
// account with an incoming shared album has one of these in its
// /collections/v2 response, so the mock must include one too.
const sharedCollectionKey = sodium.crypto_secretbox_keygen();
const sealedSharedKey = sodium.crypto_box_seal(
sharedCollectionKey,
kp.publicKey,
);
const sharedNameNonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const encSharedName = sodium.crypto_secretbox_easy(
new TextEncoder().encode("Friend's Wedding"),
sharedNameNonce,
sharedCollectionKey,
);
const rawSharedCollection = {
id: 2,
owner: { id: 99 },
encryptedKey: toBase64(sealedSharedKey),
encryptedName: toBase64(encSharedName),
nameDecryptionNonce: toBase64(sharedNameNonce),
type: "album",
updationTime: 1700000000000000,
};
const rawFile = {
id: 100,
collectionID: 1,
@@ -230,6 +260,8 @@ const buildServer = async (): Promise<ServerState> => {
// can return them. This is ugly plumbing; in a real program you
// never see any of it.
(globalThis as Record<string, unknown>).__mockRawCollection = rawCollection;
(globalThis as Record<string, unknown>).__mockRawSharedCollection =
rawSharedCollection;
(globalThis as Record<string, unknown>).__mockRawFile = rawFile;
return {
@@ -253,6 +285,7 @@ const buildServer = async (): Promise<ServerState> => {
thumbHeader: thumbPush.header,
thumbCiphertext,
collectionKey,
sharedCollectionKey,
};
};
@@ -303,9 +336,13 @@ const buildMockFetch = (s: ServerState) => {
});
}
if (path === "/collections/v2") {
const raw = (globalThis as Record<string, unknown>)
.__mockRawCollection;
return json({ collections: [raw] });
const g = globalThis as Record<string, unknown>;
return json({
collections: [
g.__mockRawCollection,
g.__mockRawSharedCollection,
],
});
}
if (path === "/collections/v2/diff") {
const raw = (globalThis as Record<string, unknown>).__mockRawFile;
@@ -412,6 +449,10 @@ describe("quak Client usage guide", () => {
* encryption key. `listCollections()` fetches them from the server,
* decrypts the keys and names, and returns typed objects.
*
* This works transparently for both kinds of collection: ones you
* own (key encrypted with your master key) and ones shared with you
* (key sealed to your public key, flagged with `isShared: true`).
*
* ```ts
* const collections = await client.listCollections();
* for (const c of collections) {
@@ -419,7 +460,7 @@ describe("quak Client usage guide", () => {
* }
* ```
*/
it("3. list and decrypt collections", async () => {
it("3. list and decrypt collections, owned and shared", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
@@ -428,12 +469,21 @@ describe("quak Client usage guide", () => {
const collections = await client.listCollections();
expect(collections.length).toBe(1);
expect(collections[0]!.name).toBe("Vacation");
expect(collections[0]!.type).toBe("album");
expect(collections[0]!.id).toBe(1);
expect(collections.length).toBe(2);
const owned = collections.find((c) => c.id === 1)!;
expect(owned.name).toBe("Vacation");
expect(owned.type).toBe("album");
expect(owned.isShared).toBe(false);
// The decrypted collection key is available for advanced use.
expect(collections[0]!.key.length).toBe(32);
expect(owned.key.length).toBe(32);
const shared = collections.find((c) => c.id === 2)!;
expect(shared.name).toBe("Friend's Wedding");
expect(shared.isShared).toBe(true);
// The key was unsealed with our keypair; the decrypted name above
// already proves it round-trips, but check it exactly too.
expect(shared.key).toEqual(server.sharedCollectionKey);
});
/**