(feat): updated calls
This commit is contained in:
parent
fb7c9fa538
commit
ab36992bdd
15 changed files with 318 additions and 99 deletions
|
|
@ -3,5 +3,5 @@ import { useCall } from "../context";
|
|||
export default function SidebarBox() {
|
||||
const { state } = useCall();
|
||||
|
||||
return state === "closed" ? null : <div>Sidebar</div>;
|
||||
return state === "closed" ? null : <div>Sidebar {state}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,18 @@ import z from "zod";
|
|||
import { ttp } from "@tensamin/shared/data";
|
||||
|
||||
import { LiveKitRoom } from "@livekit/components-react";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const { send } = useTTP();
|
||||
const { encrypt, decrypt, getSharedSecret, decryptText } = useCrypto();
|
||||
const { load } = useStorage();
|
||||
const { get } = useUser();
|
||||
|
||||
const [state, setState] = useState<
|
||||
"closed" | "closing" | "connecting" | "open"
|
||||
|
|
@ -21,11 +27,22 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
const [callSecret, setCallSecret] = useState<string | null>(null);
|
||||
const [livekitToken, setLivekitToken] = useState<string | null>(null);
|
||||
|
||||
function connect(callId: string, callSecret: string) {
|
||||
const getCallToken = async (callId: string) => {
|
||||
const response = await send("call_token", {
|
||||
call_id: callId,
|
||||
}).catch((err) => {
|
||||
log(1, "call", "red", "Failed to get call secret", err);
|
||||
throw err;
|
||||
});
|
||||
return response.data.call_token;
|
||||
};
|
||||
|
||||
async function connect(callId: string) {
|
||||
setState("connecting");
|
||||
setCallId(callId);
|
||||
setCallSecret(callSecret);
|
||||
log(2, "Call", "purple", "Connecting to call", { callId, callSecret });
|
||||
setLivekitToken(await getCallToken(callId));
|
||||
log(2, "call", "purple", "Connecting to call", { callId, callSecret });
|
||||
}
|
||||
|
||||
// Master reset function
|
||||
|
|
@ -37,19 +54,42 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
// Utils
|
||||
function joinCall(userId: number, callId?: string) {
|
||||
// get/generate e2ee secret
|
||||
/// go into convs and find call id
|
||||
/// generate shared secret and decrypt enc call secret
|
||||
async function joinCall(
|
||||
userId: number,
|
||||
callSecret?: string,
|
||||
callId?: string,
|
||||
) {
|
||||
log(2, "call", "purple", "Call creation initialised");
|
||||
if (callSecret) {
|
||||
try {
|
||||
const sharedSecret = await getSharedSecret(
|
||||
await load("private_key"),
|
||||
await get((await load("user_id")) as number).then(
|
||||
(res) => res.public_key,
|
||||
),
|
||||
await get(userId).then((res) => res.public_key),
|
||||
);
|
||||
const decryptedSecret = await decryptText(sharedSecret, callSecret);
|
||||
setCallSecret(decryptedSecret);
|
||||
} catch (err) {
|
||||
log(1, "call", "red", "Failed getting call secret", err);
|
||||
disconnect();
|
||||
}
|
||||
} else {
|
||||
setCallSecret(crypto.randomUUID());
|
||||
}
|
||||
try {
|
||||
// check if user is already in call
|
||||
|
||||
// check if user is already in call
|
||||
// connect to call
|
||||
connect(callId || crypto.randomUUID());
|
||||
setView("grid");
|
||||
|
||||
// connect to call
|
||||
connect("", "");
|
||||
setView("grid");
|
||||
|
||||
// navigate to call page
|
||||
navigate({ to: "/call", search: { userId: userId, callId: callId } });
|
||||
// navigate to call page
|
||||
navigate({ to: "/call", search: { userId: userId, callId: callId } });
|
||||
} catch (err) {
|
||||
log(1, "call", "red", "Failed to join call [navbar level]", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listener for incoming calls
|
||||
|
|
@ -60,14 +100,14 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
*/
|
||||
const [view, setView] = useState<"preview" | "focused" | "grid">("preview");
|
||||
const [currentCallData, setCurrentCallData] = useState<z.infer<
|
||||
typeof ttp.get_call_data.response
|
||||
typeof ttp.call_data.response
|
||||
> | null>(null);
|
||||
|
||||
// Get current call information for preview view
|
||||
useEffect(() => {
|
||||
if (view !== "preview" || !callId) return;
|
||||
|
||||
send("get_call_data", { call_id: callId })
|
||||
send("call_data", { call_id: callId })
|
||||
.then((data) => {
|
||||
setCurrentCallData(data.data);
|
||||
})
|
||||
|
|
@ -77,7 +117,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
error: err,
|
||||
});
|
||||
setCurrentCallData({
|
||||
someInfo: "Failed",
|
||||
users: [],
|
||||
});
|
||||
});
|
||||
}, [callId, send, view]);
|
||||
|
|
@ -98,7 +138,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
<LiveKitRoom
|
||||
serverUrl="wss://call.tensamin.net"
|
||||
token={livekitToken || ""}
|
||||
connect={state !== "closed"}
|
||||
connect={state !== "closed" && livekitToken !== ""}
|
||||
options={{
|
||||
encryption: {
|
||||
e2eeManager: {
|
||||
|
|
@ -128,17 +168,17 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
},
|
||||
|
||||
// Encryption
|
||||
encryptData: async (data: Uint8Array) => {
|
||||
console.log(callSecret);
|
||||
encryptData: async (data: Uint8Array<ArrayBuffer>) => {
|
||||
const encrypted = await encrypt(callSecret || "", data);
|
||||
return {
|
||||
uuid: "",
|
||||
payload: data,
|
||||
payload: encrypted,
|
||||
iv: new Uint8Array(),
|
||||
keyIndex: 0,
|
||||
};
|
||||
},
|
||||
handleEncryptedData: async (
|
||||
payload: Uint8Array,
|
||||
payload: Uint8Array<ArrayBuffer>,
|
||||
iv: Uint8Array,
|
||||
participantIdentity: string,
|
||||
keyIndex: number,
|
||||
|
|
@ -146,18 +186,24 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
void iv;
|
||||
void participantIdentity;
|
||||
void keyIndex;
|
||||
console.log(callSecret);
|
||||
|
||||
const decrypted = await decrypt(callSecret || "", payload);
|
||||
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
payload,
|
||||
uuid: "",
|
||||
payload: decrypted,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
loggerName: "call",
|
||||
}}
|
||||
onConnected={() => setState("open")}
|
||||
onDisconnected={() => setState("closed")}
|
||||
onError={(error) => log(1, "Call", "red", error.message)}
|
||||
onError={(error) => {
|
||||
log(1, "call", "red", error.message);
|
||||
toast("error", error.message);
|
||||
}}
|
||||
onMediaDeviceFailure={(failure, kind) => {
|
||||
log(1, "call", "red", "Media device failure", { failure, kind });
|
||||
toast("error", "Media device failure. See console for details.");
|
||||
|
|
@ -182,8 +228,8 @@ type contextType = {
|
|||
setView: (view: "preview" | "focused" | "grid") => void;
|
||||
connect: (callId: string, callSecret: string) => void;
|
||||
disconnect: () => void;
|
||||
joinCall: (userId: number, callId?: string) => void;
|
||||
currentCallData: z.infer<typeof ttp.get_call_data.response> | null;
|
||||
joinCall: (userId: number, callSecret?: string, callId?: string) => void;
|
||||
currentCallData: z.infer<typeof ttp.call_data.response> | null;
|
||||
};
|
||||
|
||||
export function useCall(): contextType {
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ import { useCall } from "../context";
|
|||
export default function Preview() {
|
||||
const { currentCallData } = useCall();
|
||||
|
||||
return <div>Preview View {currentCallData?.someInfo}</div>;
|
||||
return <div>Preview View {JSON.stringify(currentCallData?.users)}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { Button } from "@tensamin/ui";
|
|||
import { Plus, Laugh, Clapperboard } from "lucide-react";
|
||||
import { useChat } from "../context";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import Message from "./message";
|
||||
import { cn, useIsMobile } from "@tensamin/ui";
|
||||
import { encryptText } from "@tensamin/crypto/worker";
|
||||
|
||||
export default function InputComponent({
|
||||
value,
|
||||
|
|
@ -22,7 +22,6 @@ export default function InputComponent({
|
|||
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
||||
const measurementRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { encrypt } = useCrypto();
|
||||
const { send } = useTTP();
|
||||
const { addLiveMessage, sharedSecret, userId } = useChat();
|
||||
const { load } = useStorage();
|
||||
|
|
@ -80,7 +79,7 @@ export default function InputComponent({
|
|||
message_state: "awaiting",
|
||||
});
|
||||
|
||||
const encryptedContext = await encrypt(sharedSecret, currentValue);
|
||||
const encryptedContext = await encryptText(sharedSecret, currentValue);
|
||||
|
||||
send("message_send", {
|
||||
height,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@tensamin/ui";
|
||||
import { decryptText } from "@tensamin/crypto/worker";
|
||||
|
||||
function generateFixedLoadingSize(size: number, height: number) {
|
||||
return size * 3 - (height / 20) * 11;
|
||||
|
|
@ -68,7 +69,7 @@ function MessageComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
decrypt(secret, content)
|
||||
decryptText(secret, content)
|
||||
.then((value) => {
|
||||
if (!active) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ function getUninitializedApi(): null {
|
|||
}
|
||||
|
||||
describe("createCryptoActions", () => {
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
test("throws when API is not initialized", async () => {
|
||||
const actions = createCryptoActions(getUninitializedApi);
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
await actions.encrypt("ab", "plain");
|
||||
await actions.encrypt("ab", new TextEncoder().encode("plain"));
|
||||
} catch (error) {
|
||||
failed = (error as Error).message.includes("API not initialized");
|
||||
}
|
||||
|
|
@ -25,10 +28,22 @@ describe("createCryptoActions", () => {
|
|||
|
||||
test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => {
|
||||
const api = {
|
||||
encrypt: async (secret: string, plaintext: string): Promise<string> =>
|
||||
encrypt: async (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> =>
|
||||
textEncoder.encode(`${secret}:${textDecoder.decode(input)}`),
|
||||
decrypt: async (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> =>
|
||||
textEncoder.encode(`${secret}|${textDecoder.decode(input)}`),
|
||||
encryptText: async (secret: string, plaintext: string): Promise<string> =>
|
||||
`${secret}:${plaintext}`,
|
||||
decrypt: async (secret: string, ciphertext: string): Promise<string> =>
|
||||
`${secret}|${ciphertext}`,
|
||||
decryptText: async (
|
||||
secret: string,
|
||||
ciphertext: string,
|
||||
): Promise<string> => `${secret}|${ciphertext}`,
|
||||
getSharedSecret: async (
|
||||
ownPrivateKey: string,
|
||||
ownPublicKey: string,
|
||||
|
|
@ -39,8 +54,14 @@ describe("createCryptoActions", () => {
|
|||
|
||||
const actions = createCryptoActions(() => api);
|
||||
|
||||
expect(await actions.encrypt("s", "p")).toBe("s:p");
|
||||
expect(await actions.decrypt("s", "c")).toBe("s|c");
|
||||
expect(
|
||||
textDecoder.decode(await actions.encrypt("s", textEncoder.encode("p"))),
|
||||
).toBe("s:p");
|
||||
expect(
|
||||
textDecoder.decode(await actions.decrypt("s", textEncoder.encode("c"))),
|
||||
).toBe("s|c");
|
||||
expect(await actions.encryptText("s", "p")).toBe("s:p");
|
||||
expect(await actions.decryptText("s", "c")).toBe("s|c");
|
||||
expect(await actions.getSharedSecret("a", "b", "c")).toBe("a.b.c");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@ import * as React from "react";
|
|||
import * as Comlink from "comlink";
|
||||
|
||||
type CryptoContextType = {
|
||||
decrypt: (secret: string, ciphertext: string) => Promise<string>;
|
||||
encrypt: (secret: string, plaintext: string) => Promise<string>;
|
||||
decrypt: (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
) => Promise<Uint8Array<ArrayBuffer>>;
|
||||
decryptText: (secret: string, ciphertext: string) => Promise<string>;
|
||||
encrypt: (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
) => Promise<Uint8Array<ArrayBuffer>>;
|
||||
encryptText: (secret: string, plaintext: string) => Promise<string>;
|
||||
getSharedSecret: (
|
||||
ownPrivateKey: string,
|
||||
ownPublicKey: string,
|
||||
|
|
@ -12,8 +20,16 @@ type CryptoContextType = {
|
|||
};
|
||||
|
||||
type ApiRef = {
|
||||
encrypt: (secret: string, plaintext: string) => Promise<string>;
|
||||
decrypt: (secret: string, ciphertext: string) => Promise<string>;
|
||||
encrypt: (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
) => Promise<Uint8Array<ArrayBuffer>>;
|
||||
decrypt: (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
) => Promise<Uint8Array<ArrayBuffer>>;
|
||||
decryptText: (secret: string, ciphertext: string) => Promise<string>;
|
||||
encryptText: (secret: string, plaintext: string) => Promise<string>;
|
||||
getSharedSecret: (
|
||||
ownPrivateKey: string,
|
||||
ownPublicKey: string,
|
||||
|
|
@ -21,6 +37,19 @@ type ApiRef = {
|
|||
) => Promise<string>;
|
||||
};
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array<ArrayBuffer>): string {
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export const context = React.createContext<CryptoContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
|
@ -45,6 +74,16 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
if (!api) throw new Error("API not initialized");
|
||||
return await api.decrypt(secret, ciphertext);
|
||||
},
|
||||
encryptText: async (secret, plaintext) => {
|
||||
const api = apiRef.current;
|
||||
if (!api) throw new Error("API not initialized");
|
||||
return await api.encryptText(secret, plaintext);
|
||||
},
|
||||
decryptText: async (secret, ciphertext) => {
|
||||
const api = apiRef.current;
|
||||
if (!api) throw new Error("API not initialized");
|
||||
return await api.decryptText(secret, ciphertext);
|
||||
},
|
||||
getSharedSecret: async (ownPrivateKey, ownPublicKey, otherPublicKey) => {
|
||||
const api = apiRef.current;
|
||||
if (!api) throw new Error("API not initialized");
|
||||
|
|
@ -83,33 +122,63 @@ export function createCryptoActions(
|
|||
getApiRef: () => ApiRef | null,
|
||||
): CryptoContextType {
|
||||
/**
|
||||
* Encrypts plaintext by delegating to the crypto worker API.
|
||||
* Encrypts bytes by delegating to the crypto worker API.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param plaintext Plaintext to encrypt.
|
||||
* @returns Encrypted ciphertext.
|
||||
* @param input Plaintext bytes to encrypt.
|
||||
* @returns Ciphertext bytes.
|
||||
*/
|
||||
const encrypt = async (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const api = getApiRef();
|
||||
if (!api) throw new Error("API not initialized");
|
||||
return await api.encrypt(secret, input);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypts bytes by delegating to the crypto worker API.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param input Ciphertext bytes to decrypt.
|
||||
* @returns Plaintext bytes.
|
||||
*/
|
||||
const decrypt = async (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const api = getApiRef();
|
||||
if (!api) throw new Error("API not initialized");
|
||||
return await api.decrypt(secret, input);
|
||||
};
|
||||
|
||||
/**
|
||||
* Encrypts plaintext text by delegating to the crypto worker API.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param plaintext Plaintext to encrypt.
|
||||
* @returns Base64 ciphertext.
|
||||
*/
|
||||
const encryptText = async (
|
||||
secret: string,
|
||||
plaintext: string,
|
||||
): Promise<string> => {
|
||||
const api = getApiRef();
|
||||
if (!api) throw new Error("API not initialized");
|
||||
return await api.encrypt(secret, plaintext);
|
||||
return await api.encryptText(secret, plaintext);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypts ciphertext by delegating to the crypto worker API.
|
||||
* Decrypts base64 ciphertext text by delegating to the crypto worker API.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param ciphertext Ciphertext to decrypt.
|
||||
* @param ciphertext Base64 ciphertext to decrypt.
|
||||
* @returns Decrypted plaintext.
|
||||
*/
|
||||
const decrypt = async (
|
||||
const decryptText = async (
|
||||
secret: string,
|
||||
ciphertext: string,
|
||||
): Promise<string> => {
|
||||
const api = getApiRef();
|
||||
if (!api) throw new Error("API not initialized");
|
||||
return await api.decrypt(secret, ciphertext);
|
||||
return await api.decryptText(secret, ciphertext);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -133,7 +202,7 @@ export function createCryptoActions(
|
|||
);
|
||||
};
|
||||
|
||||
return { encrypt, decrypt, getSharedSecret };
|
||||
return { encrypt, decrypt, encryptText, decryptText, getSharedSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { x448 } from "@noble/curves/ed448.js";
|
||||
import { decrypt, encrypt, getSharedSecret } from "./worker";
|
||||
import {
|
||||
decrypt,
|
||||
decryptText,
|
||||
encrypt,
|
||||
encryptText,
|
||||
getSharedSecret,
|
||||
} from "./worker";
|
||||
|
||||
/**
|
||||
* Encodes bytes to URL-safe base64 without padding.
|
||||
|
|
@ -55,22 +61,35 @@ function createPrivateKey(seed: number): Uint8Array {
|
|||
}
|
||||
|
||||
describe("crypto worker", () => {
|
||||
test("encrypt/decrypt round-trip returns original plaintext", async () => {
|
||||
const secret = "a1".repeat(56);
|
||||
const plaintext = "hello encrypted world";
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const ciphertext = await encrypt(secret, plaintext);
|
||||
const decrypted = await decrypt(secret, ciphertext);
|
||||
test("encrypt/decrypt byte round-trip returns original plaintext", async () => {
|
||||
const secret = "0f".repeat(56);
|
||||
const input = "hello encrypted world";
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
const encryptedContent = await encrypt(secret, textEncoder.encode(input));
|
||||
const decryptedContent = await decrypt(secret, encryptedContent);
|
||||
|
||||
expect(textDecoder.decode(decryptedContent)).toBe(input);
|
||||
});
|
||||
|
||||
test("encryptText/decryptText round-trip returns original plaintext", async () => {
|
||||
const secret = "0f".repeat(56);
|
||||
const input = "hello encrypted world";
|
||||
|
||||
const ciphertext = await encryptText(secret, input);
|
||||
const plaintext = await decryptText(secret, ciphertext);
|
||||
|
||||
expect(plaintext).toBe(input);
|
||||
});
|
||||
|
||||
test("decrypt fails with wrong shared secret", async () => {
|
||||
const secret = "0f".repeat(56);
|
||||
const wrongSecret = "f0".repeat(56);
|
||||
const plaintext = "sensitive";
|
||||
const input = "sensitive";
|
||||
|
||||
const ciphertext = await encrypt(secret, plaintext);
|
||||
const ciphertext = await encrypt(secret, textEncoder.encode(input));
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,18 +10,44 @@ type JWK = {
|
|||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
const crypto = globalThis.crypto;
|
||||
|
||||
/**
|
||||
* Encrypts plaintext with a symmetric key derived from a hex shared secret.
|
||||
* Encodes bytes as standard base64 text.
|
||||
* @param bytes Bytes to encode.
|
||||
* @returns Base64 string.
|
||||
*/
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes standard base64 text into bytes.
|
||||
* @param base64 Base64 string.
|
||||
* @returns Decoded bytes.
|
||||
*/
|
||||
function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts bytes with a symmetric key derived from a hex shared secret.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param plaintext UTF-8 plaintext to encrypt.
|
||||
* @returns Base64-encoded ciphertext.
|
||||
* @param input Plaintext bytes to encrypt.
|
||||
* @returns Ciphertext bytes.
|
||||
*/
|
||||
export async function encrypt(
|
||||
secret: string,
|
||||
plaintext: string,
|
||||
): Promise<string> {
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const sharedSecret = new Uint8Array(
|
||||
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
|
||||
);
|
||||
|
|
@ -60,30 +86,26 @@ export async function encrypt(
|
|||
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
aesKey,
|
||||
textEncoder.encode(plaintext),
|
||||
input,
|
||||
);
|
||||
|
||||
return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer)));
|
||||
return new Uint8Array(encryptedBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret.
|
||||
* Decrypts bytes with a symmetric key derived from a hex shared secret.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param ciphertext Base64 ciphertext to decrypt.
|
||||
* @returns Decrypted UTF-8 plaintext.
|
||||
* @param input Ciphertext bytes to decrypt.
|
||||
* @returns Plaintext bytes.
|
||||
*/
|
||||
export async function decrypt(
|
||||
secret: string,
|
||||
ciphertext: Base64URLString | string,
|
||||
): Promise<string> {
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const sharedSecret = new Uint8Array(
|
||||
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
|
||||
);
|
||||
|
||||
const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
|
||||
const hkdfKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecret,
|
||||
|
|
@ -121,10 +143,38 @@ export async function decrypt(
|
|||
iv: nonce,
|
||||
},
|
||||
aesKey,
|
||||
ciphertextBytes,
|
||||
input,
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
return new Uint8Array(decryptedBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts UTF-8 text and returns base64 ciphertext for easy transport/storage.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param plaintext Text to encrypt.
|
||||
* @returns Base64 ciphertext.
|
||||
*/
|
||||
export async function encryptText(
|
||||
secret: string,
|
||||
plaintext: string,
|
||||
): Promise<string> {
|
||||
const encrypted = await encrypt(secret, textEncoder.encode(plaintext));
|
||||
return bytesToBase64(encrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts base64 ciphertext into UTF-8 text.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param ciphertext Base64 ciphertext.
|
||||
* @returns Decrypted text.
|
||||
*/
|
||||
export async function decryptText(
|
||||
secret: string,
|
||||
ciphertext: string,
|
||||
): Promise<string> {
|
||||
const decrypted = await decrypt(secret, base64ToBytes(ciphertext));
|
||||
return textDecoder.decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -462,6 +512,8 @@ if (isWorkerRuntime()) {
|
|||
Comlink.expose({
|
||||
encrypt,
|
||||
decrypt,
|
||||
encryptText,
|
||||
decryptText,
|
||||
getSharedSecret,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ export const ttp = {
|
|||
communities: z.array(z.object({})).optional(),
|
||||
contacts: z.array(
|
||||
z.object({
|
||||
calls: z.array(z.uuidv4()).optional(),
|
||||
calls: z
|
||||
.array(z.object({ id: z.uuidv4(), secret: z.base64() }))
|
||||
.optional(),
|
||||
last_message_at: z.number(),
|
||||
user_id: z.number(),
|
||||
last_message: z
|
||||
|
|
@ -183,12 +185,20 @@ export const ttp = {
|
|||
},
|
||||
|
||||
// Calls
|
||||
get_call_data: {
|
||||
call_token: {
|
||||
request: z.object({
|
||||
call_id: z.string(),
|
||||
}),
|
||||
response: z.object({
|
||||
someInfo: z.string(),
|
||||
call_token: z.string(),
|
||||
}),
|
||||
},
|
||||
call_data: {
|
||||
request: z.object({
|
||||
call_id: z.string(),
|
||||
}),
|
||||
response: z.object({
|
||||
users: z.array(z.number()),
|
||||
}),
|
||||
},
|
||||
} satisfies Record<string, { request: z.ZodType; response: z.ZodType }>;
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@ import { useTTP } from "@tensamin/ttp";
|
|||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { decryptText } from "@tensamin/crypto/worker";
|
||||
|
||||
export default function Wrapper({ children }: { children: ReactNode }) {
|
||||
const { get } = useUser();
|
||||
const { load } = useStorage();
|
||||
const { send } = useTTP();
|
||||
const { decrypt, getSharedSecret } = useCrypto();
|
||||
const { getSharedSecret } = useCrypto();
|
||||
const { searchStr } = useLocation();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -59,7 +60,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
|
|||
});
|
||||
|
||||
// Solve Challenge
|
||||
const solvedChallenge = await decrypt(sharedSecret, challenge).catch(
|
||||
const solvedChallenge = await decryptText(sharedSecret, challenge).catch(
|
||||
(err) => {
|
||||
log(1, "tauth", "red", "Failed to solve challenge", err, {
|
||||
challenge,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { ErrorScreen } from "@tensamin/ui";
|
|||
|
||||
import { version } from "../../../package.json";
|
||||
import z from "zod";
|
||||
import { decryptText } from "@tensamin/crypto/worker";
|
||||
|
||||
const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([
|
||||
"error",
|
||||
|
|
@ -477,13 +478,13 @@ export function Provider(props: {
|
|||
});
|
||||
|
||||
// Challenge Decryption
|
||||
const decryptedChallenge = await decrypt(
|
||||
const decryptedChallenge = await decryptText(
|
||||
sharedSecret,
|
||||
challengeEnvelope.data.challenge,
|
||||
).catch((decryptionError) => {
|
||||
throw new Error(
|
||||
"Failed to decrypt identification challenge",
|
||||
decryptionError,
|
||||
"Failed to decrypt identification challenge: " +
|
||||
String(decryptionError),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue