diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 632cd590cc..fa6eae5f1a 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -5,7 +5,7 @@ import { toB64URLSafeNoPaddingString, } from "@/base/crypto/libsodium"; import { isDevBuild } from "@/base/env"; -import { clientPackageHeader, ensureOk, HTTPError } from "@/base/http"; +import { ensureOk, HTTPError, publicRequestHeaders } from "@/base/http"; import { apiURL } from "@/base/origins"; import { TwoFactorAuthorizationResponse } from "@/base/types/credentials"; import { nullToUndefined } from "@/utils/transform"; @@ -412,7 +412,7 @@ export const beginPasskeyAuthentication = async ( const url = await apiURL("/users/two-factor/passkeys/begin"); const res = await fetch(url, { method: "POST", - headers: clientPackageHeader(), + headers: publicRequestHeaders(), body: JSON.stringify({ sessionID: passkeySessionID }), }); if (!res.ok) { diff --git a/web/apps/cast/src/pages/index.tsx b/web/apps/cast/src/pages/index.tsx index c3782be674..c89313d10e 100644 --- a/web/apps/cast/src/pages/index.tsx +++ b/web/apps/cast/src/pages/index.tsx @@ -9,8 +9,8 @@ import { getCastData, register } from "services/pair"; import { advertiseOnChromecast } from "../services/chromecast-receiver"; export default function Index() { - const [publicKeyB64, setPublicKeyB64] = useState(); - const [privateKeyB64, setPrivateKeyB64] = useState(); + const [publicKey, setPublicKey] = useState(); + const [privateKey, setPrivateKey] = useState(); const [pairingCode, setPairingCode] = useState(); const router = useRouter(); @@ -18,8 +18,8 @@ export default function Index() { useEffect(() => { if (!pairingCode) { void register().then((r) => { - setPublicKeyB64(r.publicKeyB64); - setPrivateKeyB64(r.privateKeyB64); + setPublicKey(r.publicKey); + setPrivateKey(r.privateKey); setPairingCode(r.pairingCode); }); } else { @@ -31,14 +31,17 @@ export default function Index() { }, [pairingCode]); useEffect(() => { - if (!publicKeyB64 || !privateKeyB64 || !pairingCode) return; + if (!publicKey || !privateKey || !pairingCode) return; const pollTick = async () => { - const registration = { publicKeyB64, privateKeyB64, pairingCode }; try { // TODO: // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const data = await getCastData(registration); + const data = await getCastData({ + publicKey, + privateKey, + pairingCode, + }); if (!data) { // No one has connected yet. return; @@ -58,7 +61,7 @@ export default function Index() { const interval = setInterval(pollTick, 2000); return () => clearInterval(interval); - }, [publicKeyB64, privateKeyB64, pairingCode, router]); + }, [publicKey, privateKey, pairingCode, router]); return ( diff --git a/web/apps/cast/src/services/pair.ts b/web/apps/cast/src/services/pair.ts index cc30ef1d1c..613bce6aa0 100644 --- a/web/apps/cast/src/services/pair.ts +++ b/web/apps/cast/src/services/pair.ts @@ -1,15 +1,24 @@ -import { boxSealOpen, generateKeyPair } from "@/base/crypto/libsodium"; +import { boxSealOpen, generateKeyPair } from "@/base/crypto"; +import { ensureOk, publicRequestHeaders } from "@/base/http"; import log from "@/base/log"; +import { apiURL } from "@/base/origins"; import { wait } from "@/utils/promise"; -import castGateway from "@ente/shared/network/cast"; +import { nullToUndefined } from "@/utils/transform"; +import { z } from "zod"; export interface Registration { /** A pairing code shown on the screen. A client can use this to connect. */ pairingCode: string; - /** The public part of the keypair we registered with the server. */ - publicKeyB64: string; - /** The private part of the keypair we registered with the server. */ - privateKeyB64: string; + /** + * A base64 string representation of the public part of the keypair we + * registered with the server. + */ + publicKey: string; + /** + * A base64 string representation of the private part of the keypair we + * registered with the server. + */ + privateKey: string; } /** @@ -74,14 +83,13 @@ export interface Registration { */ export const register = async (): Promise => { // Generate keypair. - const { publicKey: publicKeyB64, privateKey: privateKeyB64 } = - await generateKeyPair(); + const { publicKey, privateKey } = await generateKeyPair(); // Register keypair with museum to get a pairing code. let pairingCode: string | undefined; while (true) { try { - pairingCode = await castGateway.registerDevice(publicKeyB64); + pairingCode = await registerDevice(publicKey); } catch (e) { log.error("Failed to register public key with server", e); } @@ -90,7 +98,25 @@ export const register = async (): Promise => { await wait(10000); } - return { pairingCode, publicKeyB64, privateKeyB64 }; + return { pairingCode, publicKey, privateKey }; +}; + +/** + * Register the given {@link publicKey} with remote. + * + * @returns A device code that can be used to pair with us. + */ +const registerDevice = async (publicKey: string) => { + const res = await fetch(await apiURL("/cast/device-info/"), { + method: "POST", + headers: publicRequestHeaders(), + body: JSON.stringify({ + publicKey, + }), + }); + ensureOk(res); + return z.object({ deviceCode: z.string() }).parse(await res.json()) + .deviceCode; }; /** @@ -103,11 +129,11 @@ export const register = async (): Promise => { * See: [Note: Pairing protocol]. */ export const getCastData = async (registration: Registration) => { - const { pairingCode, publicKeyB64, privateKeyB64 } = registration; + const { pairingCode, publicKey, privateKey } = registration; // The client will send us the encrypted payload using our public key that // we registered with museum. - const encryptedCastData = await castGateway.getCastData(pairingCode); + const encryptedCastData = await getEncryptedCastData(pairingCode); if (!encryptedCastData) return; // Decrypt it using the private key of the pair and return the plaintext @@ -115,11 +141,29 @@ export const getCastData = async (registration: Registration) => { // slideshow for some collection. const decryptedCastData = await boxSealOpen( encryptedCastData, - publicKeyB64, - privateKeyB64, + publicKey, + privateKey, ); // TODO: // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(atob(decryptedCastData)); }; + +/** + * Fetch encrypted cast data corresponding to the given {@link code} from remote + * if a client has already paired using it. + */ +const getEncryptedCastData = async (code: string) => { + const res = await fetch(await apiURL(`/cast/cast-data/${code}`), { + headers: publicRequestHeaders(), + }); + ensureOk(res); + return z + .object({ + // encCastData will be null if pairing hasn't happened yet for the + // given code. + encCastData: z.string().nullish().transform(nullToUndefined), + }) + .parse(await res.json()).encCastData; +}; diff --git a/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx b/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx index 7b33518870..fd3b82b0df 100644 --- a/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx +++ b/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx @@ -1,6 +1,6 @@ import { TitledMiniDialog } from "@/base/components/MiniDialog"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; -import { boxSeal } from "@/base/crypto/libsodium"; +import { boxSeal } from "@/base/crypto"; import log from "@/base/log"; import type { Collection } from "@/media/collection"; import { photosDialogZIndex } from "@/new/photos/components/utils/z-index"; @@ -188,21 +188,23 @@ export const AlbumCastDialog: React.FC = ({ )} {view == "pin" && ( <> - - - ), - }} - values={{ url: "cast.ente.io" }} - /> - - {t("enter_cast_pin_code")} + + + + ), + }} + values={{ url: "cast.ente.io" }} + /> + + {t("enter_cast_pin_code")} + = ({ buttonText={t("pair_device_to_tv")} submitButtonProps={{ sx: { mt: 1, mb: 2 } }} /> - diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index 8269f95036..9f5687aa50 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -1,7 +1,7 @@ import { clientPackageName, isDesktop } from "@/base/app"; import { sharedCryptoWorker } from "@/base/crypto"; import { encryptToB64, generateEncryptionKey } from "@/base/crypto/libsodium"; -import { clientPackageHeader, HTTPError } from "@/base/http"; +import { HTTPError, publicRequestHeaders } from "@/base/http"; import log from "@/base/log"; import { accountsAppOrigin, apiURL } from "@/base/origins"; import { TwoFactorAuthorizationResponse } from "@/base/types/credentials"; @@ -229,7 +229,7 @@ export const checkPasskeyVerificationStatus = async ( const url = await apiURL("/users/two-factor/passkeys/get-token"); const params = new URLSearchParams({ sessionID }); const res = await fetch(`${url}?${params.toString()}`, { - headers: clientPackageHeader(), + headers: publicRequestHeaders(), }); if (!res.ok) { if (res.status == 404 || res.status == 410) diff --git a/web/packages/base/crypto/ente-impl.ts b/web/packages/base/crypto/ente-impl.ts index 82b5e41152..a04c0fb0ca 100644 --- a/web/packages/base/crypto/ente-impl.ts +++ b/web/packages/base/crypto/ente-impl.ts @@ -83,3 +83,9 @@ export const _decryptMetadataJSON = async (r: { }, r.keyB64, ); + +export const _generateKeyPair = libsodium.generateKeyPair; + +export const _boxSeal = libsodium.boxSeal; + +export const _boxSealOpen = libsodium.boxSealOpen; diff --git a/web/packages/base/crypto/index.ts b/web/packages/base/crypto/index.ts index f3f69e4565..a333c5ef92 100644 --- a/web/packages/base/crypto/index.ts +++ b/web/packages/base/crypto/index.ts @@ -1,6 +1,9 @@ /** * @file Higher level functions that use the ontology of Ente's requirements. * + * For more detailed documentation of specific functions, see the corresponding + * function in `libsodium.ts`. + * * [Note: Crypto code hierarchy] * * 1. @/base/crypto (Crypto API for our code) @@ -172,8 +175,6 @@ export const encryptThumbnail = (data: BytesOrB64, key: BytesOrB64) => /** * Encrypt the given data using chunked streaming encryption, but process all * the chunks in one go. - * - * For more details, see {@link encryptStreamBytes} in `libsodium.ts`. */ export const encryptStreamBytes = async (data: Uint8Array, key: BytesOrB64) => inWorker() @@ -182,8 +183,6 @@ export const encryptStreamBytes = async (data: Uint8Array, key: BytesOrB64) => /** * Prepare for chunked streaming encryption using {@link encryptStreamChunk}. - * - * For more details, see {@link initChunkEncryption} in `libsodium.ts`. */ export const initChunkEncryption = async (key: BytesOrB64) => inWorker() @@ -192,8 +191,6 @@ export const initChunkEncryption = async (key: BytesOrB64) => /** * Encrypt a chunk as part of a chunked streaming encryption. - * - * For more details, see {@link encryptStreamChunk} in `libsodium.ts`. */ export const encryptStreamChunk = async ( data: Uint8Array, @@ -346,3 +343,33 @@ export const decryptMetadataJSON = (r: { inWorker() ? ei._decryptMetadataJSON(r) : sharedCryptoWorker().then((w) => w.decryptMetadataJSON(r)); + +/** + * Generate a new public/private keypair. + */ +export const generateKeyPair = async () => + inWorker() + ? ei._generateKeyPair() + : sharedCryptoWorker().then((w) => w.generateKeyPair()); + +/** + * Public key encryption. + */ +export const boxSeal = async (data: string, publicKey: string) => + inWorker() + ? ei._boxSeal(data, publicKey) + : sharedCryptoWorker().then((w) => w.boxSeal(data, publicKey)); + +/** + * Decrypt the result of {@link boxSeal}. + */ +export const boxSealOpen = async ( + encryptedData: string, + publicKey: string, + secretKey: string, +) => + inWorker() + ? ei._boxSealOpen(encryptedData, publicKey, secretKey) + : sharedCryptoWorker().then((w) => + w.boxSealOpen(encryptedData, publicKey, secretKey), + ); diff --git a/web/packages/base/crypto/libsodium.ts b/web/packages/base/crypto/libsodium.ts index 177a28ce33..45caed99eb 100644 --- a/web/packages/base/crypto/libsodium.ts +++ b/web/packages/base/crypto/libsodium.ts @@ -663,6 +663,64 @@ export async function completeChunkHashing(hashState: sodium.StateAddress) { return hashString; } +/** + * Generate a new public/private keypair for use with public-key encryption + * functions, and return their base64 string representations. + * + * These keys are suitable for being used with the {@link boxSeal} and + * {@link boxSealOpen} functions. + */ +export const generateKeyPair = async () => { + await sodium.ready; + const keyPair = sodium.crypto_box_keypair(); + return { + publicKey: await toB64(keyPair.publicKey), + privateKey: await toB64(keyPair.privateKey), + }; +}; + +/** + * Public key encryption. + * + * Encrypt the given {@link data} using the given {@link publicKey}. + * + * This function performs asymmetric (public-key) encryption. To decrypt the + * result, use {@link boxSealOpen}. + * + * @param data The input data to encrypt, represented as a base64 string. + * + * @param publicKey The public key to use for encryption (as a base64 string). + * + * @returns The encrypted data (as a base64 string). + */ +export const boxSeal = async (data: string, publicKey: string) => { + await sodium.ready; + return toB64( + sodium.crypto_box_seal(await fromB64(data), await fromB64(publicKey)), + ); +}; + +/** + * Decrypt the result of {@link boxSeal}. + * + * All parameters, and the result, are base64 string representations of the + * underlying data. + */ +export const boxSealOpen = async ( + encryptedData: string, + publicKey: string, + secretKey: string, +) => { + await sodium.ready; + return toB64( + sodium.crypto_box_seal_open( + await fromB64(encryptedData), + await fromB64(publicKey), + await fromB64(secretKey), + ), + ); +}; + export async function deriveKey( passphrase: string, salt: string, @@ -732,47 +790,6 @@ export async function generateSaltToDeriveKey() { return await toB64(sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES)); } -/** - * Generate a new public/private keypair, and return their base64 - * representations. - */ -export const generateKeyPair = async () => { - await sodium.ready; - const keyPair = sodium.crypto_box_keypair(); - return { - publicKey: await toB64(keyPair.publicKey), - privateKey: await toB64(keyPair.privateKey), - }; -}; - -export async function boxSealOpen( - input: string, - publicKey: string, - secretKey: string, -) { - await sodium.ready; - return await toB64( - sodium.crypto_box_seal_open( - await fromB64(input), - await fromB64(publicKey), - await fromB64(secretKey), - ), - ); -} - -/** - * Encrypt the given {@link input} using the given {@link publicKey}. - * - * This function performs asymmetric (public-key) encryption. To decrypt the - * result, use {@link boxSealOpen}. - */ -export async function boxSeal(input: string, publicKey: string) { - await sodium.ready; - return await toB64( - sodium.crypto_box_seal(await fromB64(input), await fromB64(publicKey)), - ); -} - export async function generateSubKey( key: string, subKeyLength: number, diff --git a/web/packages/base/crypto/worker.ts b/web/packages/base/crypto/worker.ts index 265dbe1c68..a5a16fc767 100644 --- a/web/packages/base/crypto/worker.ts +++ b/web/packages/base/crypto/worker.ts @@ -33,6 +33,9 @@ export class CryptoWorker { decryptStreamChunk = ei._decryptStreamChunk; decryptMetadataJSON_New = ei._decryptMetadataJSON_New; decryptMetadataJSON = ei._decryptMetadataJSON; + generateKeyPair = ei._generateKeyPair; + boxSeal = ei._boxSeal; + boxSealOpen = ei._boxSealOpen; // TODO: -- AUDIT BELOW -- @@ -93,18 +96,6 @@ export class CryptoWorker { return libsodium.generateSaltToDeriveKey(); } - async generateKeyPair() { - return libsodium.generateKeyPair(); - } - - async boxSealOpen(input: string, publicKey: string, secretKey: string) { - return libsodium.boxSealOpen(input, publicKey, secretKey); - } - - async boxSeal(input: string, publicKey: string) { - return libsodium.boxSeal(input, publicKey); - } - async generateSubKey( key: string, subKeyLength: number, diff --git a/web/packages/base/http.ts b/web/packages/base/http.ts index 89cdd73d76..fe6941a8aa 100644 --- a/web/packages/base/http.ts +++ b/web/packages/base/http.ts @@ -15,10 +15,12 @@ export const authenticatedRequestHeaders = async () => ({ }); /** - * Return a headers object with "X-Client-Package" header set to the client - * package name of the current app. + * Return headers that should be passed alongwith (almost) all unauthenticated + * `fetch` calls that we make to our API servers. + * + * - The client package name. */ -export const clientPackageHeader = () => ({ +export const publicRequestHeaders = () => ({ "X-Client-Package": clientPackageName, }); diff --git a/web/packages/gallery/services/download.ts b/web/packages/gallery/services/download.ts index f53348afac..44243932a3 100644 --- a/web/packages/gallery/services/download.ts +++ b/web/packages/gallery/services/download.ts @@ -8,7 +8,7 @@ import { } from "@/base/crypto"; import { authenticatedRequestHeaders, - clientPackageHeader, + publicRequestHeaders, retryEnsuringHTTPOk, } from "@/base/http"; import { ensureAuthToken } from "@/base/local-user"; @@ -617,7 +617,7 @@ const photos_downloadThumbnail = async (file: EnteFile) => { return fetch( `${customOrigin}/files/preview/${file.id}?${params.toString()}`, { - headers: clientPackageHeader(), + headers: publicRequestHeaders(), }, ); } else { @@ -676,7 +676,7 @@ const photos_downloadFile = async (file: EnteFile): Promise => { return fetch( `${customOrigin}/files/download/${file.id}?${params.toString()}`, { - headers: clientPackageHeader(), + headers: publicRequestHeaders(), }, ); } else { @@ -714,7 +714,7 @@ const publicAlbums_downloadThumbnail = async ( return fetch( `${customOrigin}/public-collection/files/preview/${file.id}?${params.toString()}`, { - headers: clientPackageHeader(), + headers: publicRequestHeaders(), }, ); } else { @@ -722,7 +722,7 @@ const publicAlbums_downloadThumbnail = async ( `https://public-albums.ente.io/preview/?fileID=${file.id}`, { headers: { - ...clientPackageHeader(), + ...publicRequestHeaders(), "X-Auth-Access-Token": accessToken, ...(accessTokenJWT ? { "X-Auth-Access-Token-JWT": accessTokenJWT } @@ -758,7 +758,7 @@ const publicAlbums_downloadFile = async ( `https://public-albums.ente.io/download/?fileID=${file.id}`, { headers: { - ...clientPackageHeader(), + ...publicRequestHeaders(), "X-Auth-Access-Token": accessToken, ...(accessTokenJWT ? { "X-Auth-Access-Token-JWT": accessTokenJWT } diff --git a/web/packages/shared/network/cast.ts b/web/packages/shared/network/cast.ts index 226696371f..3c45b0aec3 100644 --- a/web/packages/shared/network/cast.ts +++ b/web/packages/shared/network/cast.ts @@ -56,16 +56,6 @@ class CastGateway { return resp.data.publicKey; } - public async registerDevice(publicKey: string): Promise { - const resp = await HTTPService.post( - await apiURL("/cast/device-info/"), - { - publicKey: publicKey, - }, - ); - return resp.data.deviceCode; - } - public async publishCastPayload( code: string, castPayload: string,