[web] Further refactoring of the crypto layering (#2755)

\+ tweaks to the indexing process
This commit is contained in:
Manav Rathi
2024-08-19 21:20:27 +05:30
committed by GitHub
14 changed files with 278 additions and 241 deletions

View File

@@ -1,5 +1,4 @@
import { sharedCryptoWorker } from "@/base/crypto";
import { encryptMetadataJSON } from "@/base/crypto/ente";
import { encryptMetadataJSON, sharedCryptoWorker } from "@/base/crypto";
import log from "@/base/log";
import { apiURL } from "@/base/origins";
import { ItemVisibility } from "@/media/file-metadata";

View File

@@ -1,5 +1,4 @@
import { sharedCryptoWorker } from "@/base/crypto";
import { decryptMetadataJSON } from "@/base/crypto/ente";
import { decryptMetadataJSON, sharedCryptoWorker } from "@/base/crypto";
import log from "@/base/log";
import { apiURL } from "@/base/origins";
import HTTPService from "@ente/shared/network/HTTPService";

View File

@@ -1,4 +1,4 @@
import { encryptMetadataJSON } from "@/base/crypto/ente";
import { encryptMetadataJSON } from "@/base/crypto";
import log from "@/base/log";
import { apiURL } from "@/base/origins";
import { getLocalFiles, setLocalFiles } from "@/new/photos/services/files";

View File

@@ -1,224 +0,0 @@
/**
* @file Higher level functions that use the ontology of Ente's types
*
* [Note: Crypto code hierarchy]
*
* 1. crypto/ente.ts (Ente specific higher level functions)
* 2. crypto/libsodium.ts (More primitive wrappers over libsodium)
* 3. libsodium-wrappers (JavaScript bindings to libsodium)
*
* Our cryptography primitives are provided by libsodium, specifically, its
* JavaScript bindings ("libsodium-wrappers"). This is the lowest layer.
*
* Direct usage of "libsodium-wrappers" is restricted to `crypto/libsodium.ts`.
* This is the next higher layer, and the first one that our code should
* directly use. Usually the functions in this file are thin wrappers over the
* raw libsodium APIs, with a bit of massaging. They also ensure that
* sodium.ready has been called before accessing libsodium's APIs, thus all the
* functions it exposes are async.
*
* The highest layer is this file, `crypto/ente.ts`. These are usually simple
* compositions of functionality exposed by `crypto/libsodium.ts`, but the
* difference is that the functions in ente.ts don't talk in terms of the crypto
* algorithms, but rather in terms the higher-level Ente specific goal we are
* trying to accomplish.
*
* There is an additional actor in the play. Cryptographic operations are CPU
* intensive and would cause the UI to stutter if used directly on the main
* thread. To keep the UI smooth, we instead want to run them in a web worker.
* However, sometimes we already _are_ running in a web worker, and delegating
* to another worker is wasteful.
*
* To handle both these scenario, each function in this file is split into the
* external API, and the underlying implementation (denoted by an "_" prefix).
* The external API functions check to see if we're already in a web worker, and
* if so directly invoke the implementation. Otherwise the call the sibling
* function in a shared "crypto" web worker (which then invokes the
* implementation, but this time in the context of a web worker).
*
* To avoid a circular dependency during webpack imports, we need to keep the
* implementation functions in a separate file (ente-impl.ts). This is a bit
* unfortunate, since it makes them harder to read and reason about (since their
* documentation and parameter names are all in ente.ts).
*
* Some older code directly calls the functions in the shared crypto.worker.ts,
* but that should be avoided since it makes the code not behave the way we want
* when we're already in a web worker. There are exceptions to this
* recommendation though (in circumstances where we create more crypto workers
* instead of using the shared one).
*/
import { sharedCryptoWorker } from ".";
import { assertionFailed } from "../assert";
import { inWorker } from "../env";
import * as ei from "./ente-impl";
import type { BytesOrB64, EncryptedBlob, EncryptedBox } from "./types";
/**
* Some of these functions have not yet been needed on the main thread, and for
* these we don't have a corresponding sharedCryptoWorker method.
*
* This assertion will let us know when we need to implement them. This will
* gracefully degrade in production: the functionality will work, just that the
* crypto operations will happen on the main thread itself.
*/
const assertInWorker = <T>(x: T): T => {
if (!inWorker()) assertionFailed("Currently only usable in a web worker");
return x;
};
/**
* Encrypt the given data, returning a box containing the encrypted data and a
* randomly generated nonce that was used during encryption.
*
* Both the encrypted data and the nonce are returned as base64 strings.
*
* Use {@link decryptBoxB64} to decrypt the result.
*
* > The suffix "Box" comes from the fact that it uses the so called secretbox
* > APIs provided by libsodium under the hood.
* >
* > See: [Note: 3 forms of encryption (Box | Blob | Stream)]
*/
export const encryptBoxB64 = (data: BytesOrB64, key: BytesOrB64) =>
inWorker()
? ei._encryptBoxB64(data, key)
: sharedCryptoWorker().then((w) => w.encryptBoxB64(data, key));
/**
* Encrypt the given data, returning a blob containing the encrypted data and a
* decryption header.
*
* This function is usually used to encrypt data associated with an Ente object
* (file, collection, entity) using the object's key.
*
* Use {@link decryptBlob} to decrypt the result.
*
* > The suffix "Blob" comes from our convention of naming functions that use
* > the secretstream APIs in one-shot mode.
* >
* > See: [Note: 3 forms of encryption (Box | Blob | Stream)]
*/
export const encryptBlob = (data: BytesOrB64, key: BytesOrB64) =>
assertInWorker(ei._encryptBlob(data, key));
/**
* A variant of {@link encryptBlob} that returns the result components as base64
* strings.
*/
export const encryptBlobB64 = (data: BytesOrB64, key: BytesOrB64) =>
assertInWorker(ei._encryptBlobB64(data, key));
/**
* Encrypt the thumbnail for a file.
*
* This is midway variant of {@link encryptBlob} and {@link encryptBlobB64} that
* returns the decryption header as a base64 string, but leaves the data
* unchanged.
*
* Use {@link decryptThumbnail} to decrypt the result.
*/
export const encryptThumbnail = (data: BytesOrB64, key: BytesOrB64) =>
assertInWorker(ei._encryptThumbnail(data, key));
/**
* Encrypt the JSON metadata associated with an Ente object (file, collection or
* entity) using the object's key.
*
* This is a variant of {@link encryptBlobB64} tailored for encrypting any
* arbitrary metadata associated with an Ente object. For example, it is used
* for encrypting the various metadata fields associated with a file, using that
* file's key.
*
* Instead of raw bytes, it takes as input an arbitrary JSON object which it
* encodes into a string, and encrypts that.
*
* Use {@link decryptMetadataJSON} to decrypt the result.
*
* @param jsonValue The JSON value to encrypt. This can be an arbitrary JSON
* value, but since TypeScript currently doesn't have a native JSON type, it is
* typed as {@link unknown}.
*
* @param key The encryption key.
*/
export const encryptMetadataJSON_New = (jsonValue: unknown, key: BytesOrB64) =>
inWorker()
? ei._encryptMetadataJSON_New(jsonValue, key)
: sharedCryptoWorker().then((w) =>
w.encryptMetadataJSON_New(jsonValue, key),
);
/**
* Deprecated, use {@link encryptMetadataJSON_New} instead.
*/
export const encryptMetadataJSON = async (r: {
jsonValue: unknown;
keyB64: string;
}) =>
inWorker()
? ei._encryptMetadataJSON(r)
: sharedCryptoWorker().then((w) => w.encryptMetadataJSON(r));
/**
* Decrypt a box encrypted using {@link encryptBoxB64}.
*/
export const decryptBox = (box: EncryptedBox, key: BytesOrB64) =>
inWorker()
? ei._decryptBox(box, key)
: sharedCryptoWorker().then((w) => w.decryptBox(box, key));
/**
* Variant of {@link decryptBox} that returns the result as a base64 string.
*/
export const decryptBoxB64 = (box: EncryptedBox, key: BytesOrB64) =>
inWorker()
? ei._decryptBoxB64(box, key)
: sharedCryptoWorker().then((w) => w.decryptBoxB64(box, key));
/**
* Decrypt a blob encrypted using either {@link encryptBlob} or
* {@link encryptBlobB64}.
*/
export const decryptBlob = (blob: EncryptedBlob, key: BytesOrB64) =>
assertInWorker(ei._decryptBlob(blob, key));
/**
* A variant of {@link decryptBlob} that returns the result as a base64 string.
*/
export const decryptBlobB64 = (blob: EncryptedBlob, key: BytesOrB64) =>
inWorker()
? ei._decryptBlobB64(blob, key)
: sharedCryptoWorker().then((w) => w.decryptBlobB64(blob, key));
/**
* Decrypt the thumbnail encrypted using {@link encryptThumbnail}.
*/
export const decryptThumbnail = (blob: EncryptedBlob, key: BytesOrB64) =>
assertInWorker(ei._decryptThumbnail(blob, key));
/**
* Decrypt the metadata JSON encrypted using {@link encryptMetadataJSON}.
*
* @returns The decrypted JSON value. Since TypeScript does not have a native
* JSON type, we need to return it as an `unknown`.
*/
export const decryptMetadataJSON_New = (
blob: EncryptedBlob,
key: BytesOrB64,
) =>
inWorker()
? ei._decryptMetadataJSON_New(blob, key)
: sharedCryptoWorker().then((w) =>
w.decryptMetadataJSON_New(blob, key),
);
/**
* Deprecated, retains the old API.
*/
export const decryptMetadataJSON = (r: {
encryptedDataB64: string;
decryptionHeaderB64: string;
keyB64: string;
}) =>
inWorker()
? ei._decryptMetadataJSON(r)
: sharedCryptoWorker().then((w) => w.decryptMetadataJSON(r));

View File

@@ -1,4 +1,54 @@
/**
* @file Higher level functions that use the ontology of Ente's requirements.
*
* [Note: Crypto code hierarchy]
*
* 1. @/base/crypto (Crypto API for our code)
* 2. @/base/crypto/libsodium (Lower level wrappers over libsodium)
* 3. libsodium-wrappers (JavaScript bindings to libsodium)
*
* Our cryptography primitives are provided by libsodium, specifically, its
* JavaScript bindings ("libsodium-wrappers"). This is the lowest layer.
*
* Direct usage of "libsodium-wrappers" is restricted to `crypto/libsodium.ts`.
* This is the next higher layer. Usually the functions in this file are thin
* wrappers over the raw libsodium APIs, with a bit of massaging. They also
* ensure that sodium.ready has been called before accessing libsodium's APIs,
* thus all the functions it exposes are async.
*
* The highest layer is this file, `crypto/index.ts`, and the one that our own
* code should use. These are usually simple compositions of functionality
* exposed by `crypto/libsodium.ts`, the primary difference being that these
* functions try to talk in terms of higher-level Ente specific goal we are
* trying to accomplish instead of the specific underlying crypto algorithms.
*
* There is an additional actor in play. Cryptographic operations like
* encryption are CPU intensive and would cause the UI to stutter if used
* directly on the main thread. To keep the UI smooth, we instead want to run
* them in a web worker. However, sometimes we already _are_ running in a web
* worker, and delegating to another worker is wasteful.
*
* To handle both these scenario, the potentially CPU intensive functions in
* this file are split into the external API, and the underlying implementation
* (denoted by an "_" prefix). To avoid a circular dependency during webpack
* imports, we need to keep the implementation functions in a separate file
* (`ente-impl.ts`).
*
* The external API functions check to see if we're already in a web worker, and
* if so directly invoke the implementation. Otherwise the call the sibling
* function in a shared "crypto" web worker (which then invokes the
* implementation function, but this time in the context of a web worker).
*
* Also, some code (e.g. the uploader) creates it own crypto worker instances,
* and thus directly calls the functions in the web worker (it created) instead
* of going through this file.
*/
import { ComlinkWorker } from "@/base/worker/comlink-worker";
import { assertionFailed } from "../assert";
import { inWorker } from "../env";
import * as ei from "./ente-impl";
import * as libsodium from "./libsodium";
import type { BytesOrB64, EncryptedBlob, EncryptedBox } from "./types";
import type { CryptoWorker } from "./worker";
/**
@@ -21,3 +71,182 @@ export const createComlinkCryptoWorker = () =>
"crypto",
new Worker(new URL("worker.ts", import.meta.url)),
);
/**
* Some of the potentially CPU intensive functions below have not yet been
* needed on the main thread, and for these we don't have a corresponding
* sharedCryptoWorker method.
*
* This assertion will let us know when we need to implement them. This will
* gracefully degrade in production: the functionality will work, just that the
* crypto operations will happen on the main thread itself.
*/
const assertInWorker = <T>(x: T): T => {
if (!inWorker()) assertionFailed("Currently only usable in a web worker");
return x;
};
/**
* Generate a new randomly generated 256-bit key suitable for use with the *Box
* encryption functions.
*/
export const generateBoxKey = libsodium.generateBoxKey;
/**
* Encrypt the given data, returning a box containing the encrypted data and a
* randomly generated nonce that was used during encryption.
*
* Both the encrypted data and the nonce are returned as base64 strings.
*
* Use {@link decryptBoxB64} to decrypt the result.
*
* > The suffix "Box" comes from the fact that it uses the so called secretbox
* > APIs provided by libsodium under the hood.
* >
* > See: [Note: 3 forms of encryption (Box | Blob | Stream)]
*/
export const encryptBoxB64 = (data: BytesOrB64, key: BytesOrB64) =>
inWorker()
? ei._encryptBoxB64(data, key)
: sharedCryptoWorker().then((w) => w.encryptBoxB64(data, key));
/**
* Encrypt the given data, returning a blob containing the encrypted data and a
* decryption header.
*
* This function is usually used to encrypt data associated with an Ente object
* (file, collection, entity) using the object's key.
*
* Use {@link decryptBlob} to decrypt the result.
*
* > The suffix "Blob" comes from our convention of naming functions that use
* > the secretstream APIs in one-shot mode.
* >
* > See: [Note: 3 forms of encryption (Box | Blob | Stream)]
*/
export const encryptBlob = (data: BytesOrB64, key: BytesOrB64) =>
assertInWorker(ei._encryptBlob(data, key));
/**
* A variant of {@link encryptBlob} that returns the result components as base64
* strings.
*/
export const encryptBlobB64 = (data: BytesOrB64, key: BytesOrB64) =>
assertInWorker(ei._encryptBlobB64(data, key));
/**
* Encrypt the thumbnail for a file.
*
* This is midway variant of {@link encryptBlob} and {@link encryptBlobB64} that
* returns the decryption header as a base64 string, but leaves the data
* unchanged.
*
* Use {@link decryptThumbnail} to decrypt the result.
*/
export const encryptThumbnail = (data: BytesOrB64, key: BytesOrB64) =>
assertInWorker(ei._encryptThumbnail(data, key));
/**
* Encrypt the JSON metadata associated with an Ente object (file, collection or
* entity) using the object's key.
*
* This is a variant of {@link encryptBlobB64} tailored for encrypting any
* arbitrary metadata associated with an Ente object. For example, it is used
* for encrypting the various metadata fields associated with a file, using that
* file's key.
*
* Instead of raw bytes, it takes as input an arbitrary JSON object which it
* encodes into a string, and encrypts that.
*
* Use {@link decryptMetadataJSON} to decrypt the result.
*
* @param jsonValue The JSON value to encrypt. This can be an arbitrary JSON
* value, but since TypeScript currently doesn't have a native JSON type, it is
* typed as {@link unknown}.
*
* @param key The encryption key.
*/
export const encryptMetadataJSON_New = (jsonValue: unknown, key: BytesOrB64) =>
inWorker()
? ei._encryptMetadataJSON_New(jsonValue, key)
: sharedCryptoWorker().then((w) =>
w.encryptMetadataJSON_New(jsonValue, key),
);
/**
* Deprecated, use {@link encryptMetadataJSON_New} instead.
*/
export const encryptMetadataJSON = async (r: {
jsonValue: unknown;
keyB64: string;
}) =>
inWorker()
? ei._encryptMetadataJSON(r)
: sharedCryptoWorker().then((w) => w.encryptMetadataJSON(r));
/**
* Decrypt a box encrypted using {@link encryptBoxB64}.
*/
export const decryptBox = (box: EncryptedBox, key: BytesOrB64) =>
inWorker()
? ei._decryptBox(box, key)
: sharedCryptoWorker().then((w) => w.decryptBox(box, key));
/**
* Variant of {@link decryptBox} that returns the result as a base64 string.
*/
export const decryptBoxB64 = (box: EncryptedBox, key: BytesOrB64) =>
inWorker()
? ei._decryptBoxB64(box, key)
: sharedCryptoWorker().then((w) => w.decryptBoxB64(box, key));
/**
* Decrypt a blob encrypted using either {@link encryptBlob} or
* {@link encryptBlobB64}.
*/
export const decryptBlob = (blob: EncryptedBlob, key: BytesOrB64) =>
inWorker()
? ei._decryptBlob(blob, key)
: sharedCryptoWorker().then((w) => w.decryptBlob(blob, key));
/**
* A variant of {@link decryptBlob} that returns the result as a base64 string.
*/
export const decryptBlobB64 = (blob: EncryptedBlob, key: BytesOrB64) =>
inWorker()
? ei._decryptBlobB64(blob, key)
: sharedCryptoWorker().then((w) => w.decryptBlobB64(blob, key));
/**
* Decrypt the thumbnail encrypted using {@link encryptThumbnail}.
*/
export const decryptThumbnail = (blob: EncryptedBlob, key: BytesOrB64) =>
assertInWorker(ei._decryptThumbnail(blob, key));
/**
* Decrypt the metadata JSON encrypted using {@link encryptMetadataJSON}.
*
* @returns The decrypted JSON value. Since TypeScript does not have a native
* JSON type, we need to return it as an `unknown`.
*/
export const decryptMetadataJSON_New = (
blob: EncryptedBlob,
key: BytesOrB64,
) =>
inWorker()
? ei._decryptMetadataJSON_New(blob, key)
: sharedCryptoWorker().then((w) =>
w.decryptMetadataJSON_New(blob, key),
);
/**
* Deprecated, retains the old API.
*/
export const decryptMetadataJSON = (r: {
encryptedDataB64: string;
decryptionHeaderB64: string;
keyB64: string;
}) =>
inWorker()
? ei._decryptMetadataJSON(r)
: sharedCryptoWorker().then((w) => w.decryptMetadataJSON(r));

View File

@@ -128,6 +128,17 @@ export async function fromHex(input: string) {
const bytes = async (bob: BytesOrB64) =>
typeof bob == "string" ? fromB64(bob) : bob;
/**
* Generate a key for use with the *Box encryption functions.
*
* This returns a new randomly generated 256-bit key suitable for being used
* with libsodium's secretbox APIs.
*/
export const generateBoxKey = async () => {
await sodium.ready;
return toB64(sodium.crypto_secretbox_keygen());
};
/**
* Encrypt the given data using libsodium's secretbox APIs, using a randomly
* generated nonce.
@@ -231,7 +242,7 @@ export const encryptBoxB64 = async (
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encryptedData = sodium.crypto_secretbox_easy(
await bytes(data),
await bytes(nonce),
nonce,
await bytes(key),
);
return {

View File

@@ -5,7 +5,8 @@ import * as libsodium from "./libsodium";
/**
* A web worker that exposes some of the functions defined in either the Ente
* specific layer (crypto/ente.ts) or the libsodium layer (crypto/libsodium.ts).
* specific layer (@/base/crypto) or the libsodium layer
* (@/base/crypto/libsodium.ts).
*
* See: [Note: Crypto code hierarchy].
*
@@ -18,6 +19,7 @@ export class CryptoWorker {
encryptMetadataJSON = ei._encryptMetadataJSON;
decryptBox = ei._decryptBox;
decryptBoxB64 = ei._decryptBoxB64;
decryptBlob = ei._decryptBlob;
decryptBlobB64 = ei._decryptBlobB64;
decryptThumbnail = ei._decryptThumbnail;
decryptMetadataJSON_New = ei._decryptMetadataJSON_New;

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { decryptBox } from "./crypto/ente";
import { decryptBox } from "./crypto";
import { toB64 } from "./crypto/libsodium";
/**

View File

@@ -1,4 +1,4 @@
import { decryptMetadataJSON, encryptMetadataJSON } from "@/base/crypto/ente";
import { decryptMetadataJSON, encryptMetadataJSON } from "@/base/crypto";
import { authenticatedRequestHeaders, ensureOk } from "@/base/http";
import { apiURL } from "@/base/origins";
import {

View File

@@ -1,4 +1,4 @@
import { encryptBlobB64 } from "@/base/crypto/ente";
import { encryptBlobB64 } from "@/base/crypto";
import { authenticatedRequestHeaders, ensureOk } from "@/base/http";
import { apiURL } from "@/base/origins";
import type { EnteFile } from "@/new/photos/types/file";

View File

@@ -490,8 +490,10 @@ const getMLStatus = async (): Promise<MLStatus> => {
const state = await (await worker()).state;
if (state == "indexing" || state == "fetching") {
phase = state;
} else if (state == "init" || indexableCount > 0) {
phase = "scheduled";
} else {
phase = indexableCount > 0 ? "scheduled" : "done";
phase = "done";
}
return {

View File

@@ -1,4 +1,4 @@
import { decryptBlob } from "@/base/crypto/ente";
import { decryptBlob } from "@/base/crypto";
import log from "@/base/log";
import type { EnteFile } from "@/new/photos/types/file";
import { nullToUndefined } from "@/utils/transform";

View File

@@ -43,6 +43,7 @@ import type { CLIPMatches, MLWorkerDelegate } from "./worker-types";
/**
* A rough hint at what the worker is up to.
*
* - "init": Worker has been created but hasn't done anything yet.
* - "idle": Not doing anything
* - "tick": Transitioning to a new state
* - "indexing": Indexing
@@ -52,7 +53,7 @@ import type { CLIPMatches, MLWorkerDelegate } from "./worker-types";
* data for more than 50% of the files that we requested from it in the last
* fetch during indexing.
*/
export type WorkerState = "idle" | "tick" | "indexing" | "fetching";
export type WorkerState = "init" | "idle" | "tick" | "indexing" | "fetching";
const idleDurationStart = 5; /* 5 seconds */
const idleDurationMax = 16 * 60; /* 16 minutes */
@@ -92,7 +93,7 @@ interface IndexableItem {
*/
export class MLWorker {
/** The last known state of the worker. */
public state: WorkerState = "idle";
public state: WorkerState = "init";
private electron: ElectronMLWorker | undefined;
private delegate: MLWorkerDelegate | undefined;
@@ -138,7 +139,7 @@ export class MLWorker {
/** Invoked in response to external events. */
private wakeUp() {
if (this.state == "idle") {
if (this.state == "init" || this.state == "idle") {
// We are currently paused. Get back to work.
if (this.idleTimeout) clearTimeout(this.idleTimeout);
this.idleTimeout = undefined;
@@ -202,7 +203,12 @@ export class MLWorker {
const liveQ = this.liveQ;
this.liveQ = [];
this.state = "indexing";
// Retain the previous state if it was one of the indexing states. This
// prevents jumping between "fetching" and "indexing" being shown in the
// UI during the initial load.
if (this.state != "fetching" && this.state != "indexing")
this.state = "indexing";
// Use the liveQ if present, otherwise get the next batch to backfill.
const items = liveQ.length ? liveQ : await this.backfillQ();

View File

@@ -1,4 +1,9 @@
import { decryptBlob, decryptBoxB64 } from "@/base/crypto/ente";
import {
decryptBlob,
decryptBoxB64,
encryptBoxB64,
generateBoxKey,
} from "@/base/crypto";
import { authenticatedRequestHeaders, ensureOk, HTTPError } from "@/base/http";
import { getKV, getKVN, setKV } from "@/base/kv";
import { apiURL } from "@/base/origins";
@@ -229,6 +234,14 @@ const saveRemoteUserEntityKey = (
entityKey: RemoteUserEntityKey,
) => setKV(entityKeyKey(type), JSON.stringify(entityKey));
/**
* Generate a new entity key and return it after encrypting it using the user's
* master key.
*/
// TODO: Temporary export to silence lint
export const generateEncryptedEntityKey = async () =>
encryptBoxB64(await generateBoxKey(), await masterKeyFromSession());
/**
* Decrypt an encrypted entity key using the user's master key.
*/