[web] Refactoring - Cast (#4178)

This commit is contained in:
Manav Rathi
2024-11-26 09:28:59 +05:30
committed by GitHub
12 changed files with 207 additions and 121 deletions

View File

@@ -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) {

View File

@@ -9,8 +9,8 @@ import { getCastData, register } from "services/pair";
import { advertiseOnChromecast } from "../services/chromecast-receiver";
export default function Index() {
const [publicKeyB64, setPublicKeyB64] = useState<string | undefined>();
const [privateKeyB64, setPrivateKeyB64] = useState<string | undefined>();
const [publicKey, setPublicKey] = useState<string | undefined>();
const [privateKey, setPrivateKey] = useState<string | undefined>();
const [pairingCode, setPairingCode] = useState<string | undefined>();
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 (
<Container>

View File

@@ -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<Registration> => {
// 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<Registration> => {
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<Registration> => {
* 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;
};

View File

@@ -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<AlbumCastDialogProps> = ({
)}
{view == "pin" && (
<>
<Typography>
<Trans
i18nKey="visit_cast_url"
components={{
a: (
<Link
target="_blank"
href="https://cast.ente.io"
/>
),
}}
values={{ url: "cast.ente.io" }}
/>
</Typography>
<Typography>{t("enter_cast_pin_code")}</Typography>
<Stack sx={{ gap: 2, mb: 2 }}>
<Typography>
<Trans
i18nKey="visit_cast_url"
components={{
a: (
<Link
target="_blank"
href="https://cast.ente.io"
/>
),
}}
values={{ url: "cast.ente.io" }}
/>
</Typography>
<Typography>{t("enter_cast_pin_code")}</Typography>
</Stack>
<SingleInputForm
callback={onSubmit}
fieldType="text"
@@ -211,7 +213,11 @@ export const AlbumCastDialog: React.FC<AlbumCastDialogProps> = ({
buttonText={t("pair_device_to_tv")}
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
/>
<Button variant="text" onClick={() => setView("choose")}>
<Button
variant="text"
fullWidth
onClick={() => setView("choose")}
>
{t("GO_BACK")}
</Button>
</>

View File

@@ -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)

View File

@@ -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;

View File

@@ -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),
);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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<Response> => {
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 }

View File

@@ -56,16 +56,6 @@ class CastGateway {
return resp.data.publicKey;
}
public async registerDevice(publicKey: string): Promise<string> {
const resp = await HTTPService.post(
await apiURL("/cast/device-info/"),
{
publicKey: publicKey,
},
);
return resp.data.deviceCode;
}
public async publishCastPayload(
code: string,
castPayload: string,