(feat): more crypto migration
This commit is contained in:
parent
f3ecb8f3dd
commit
2777ba34ca
11 changed files with 504 additions and 715 deletions
|
|
@ -15,6 +15,7 @@ import { useChat } from "../context";
|
|||
import { useMTP } from "@tensamin/mtp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { cn, useIsMobile } from "@tensamin/ui";
|
||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import GifPicker from "./gifPicker";
|
||||
|
|
@ -28,8 +29,8 @@ export default function InputComponent({
|
|||
}) {
|
||||
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
||||
|
||||
const { sendEncrypted } = useMTP();
|
||||
const { addLiveMessage, userId, inputBoxRef } = useChat();
|
||||
const { send } = useMTP();
|
||||
const { addLiveMessage, chatSecret, userId, inputBoxRef } = useChat();
|
||||
const { load, save } = useStorage();
|
||||
const { moveUserIdToTop } = useSession();
|
||||
const gifPopoverRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -64,6 +65,11 @@ export default function InputComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!chatSecret) {
|
||||
toast("error", "Still getting chat secret...");
|
||||
return;
|
||||
}
|
||||
|
||||
log(3, "chat", "purple", "Message send init, adding live message ...");
|
||||
|
||||
const reference = addLiveMessage({
|
||||
|
|
@ -74,38 +80,33 @@ export default function InputComponent({
|
|||
MessageState: "awaiting",
|
||||
});
|
||||
|
||||
log(3, "chat", "purple", "Live message added, sending encrypted frame...");
|
||||
log(3, "chat", "purple", "Live message added, encrypting...");
|
||||
|
||||
void load("user_id")
|
||||
.then((ownUserId) =>
|
||||
sendEncrypted(
|
||||
"MessageSend",
|
||||
{
|
||||
Content: currentValue,
|
||||
const encryptedContent = await encryptChatText(chatSecret, currentValue).catch(
|
||||
(err) => {
|
||||
toast("error", "Failed to encrypt message", String(err));
|
||||
reference.setFailed(true);
|
||||
},
|
||||
);
|
||||
|
||||
if (!encryptedContent) return;
|
||||
|
||||
log(3, "chat", "purple", "Content encrypted, sending message...");
|
||||
|
||||
send("MessageSend", {
|
||||
Content: encryptedContent,
|
||||
ReceiverId: userId,
|
||||
SendTime: time,
|
||||
},
|
||||
{
|
||||
senderUserId: String(ownUserId),
|
||||
recipientUserId: String(userId),
|
||||
},
|
||||
),
|
||||
)
|
||||
.catch((e) => {
|
||||
log(0, "Chat", "red", "Failed to send encrypted message", e, {
|
||||
}).catch((e) => {
|
||||
log(0, "Chat", "red", "Failed to send message", e, {
|
||||
ReceiverId: userId,
|
||||
SendTime: time,
|
||||
});
|
||||
reference.setFailed(true);
|
||||
toast(
|
||||
"error",
|
||||
e instanceof Error && e.message.includes("public key")
|
||||
? "Recipient has no encryption public key available"
|
||||
: "Failed to send encrypted message",
|
||||
);
|
||||
toast("error", "Failed to send message");
|
||||
});
|
||||
|
||||
log(3, "chat", "purple", "Encrypted message send queued");
|
||||
log(3, "chat", "purple", "Message sent");
|
||||
|
||||
moveUserIdToTop(userId);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,39 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { useRouterState } from "@tanstack/react-router";
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
import type { LiveMessage, RawMessage, RawMessages } from "./values";
|
||||
import {
|
||||
deriveChatId,
|
||||
deriveChatSecretId,
|
||||
decryptChatText,
|
||||
encryptChatText,
|
||||
kemPublicKeyFromPublicKeyBundle,
|
||||
ownKemPublicKeyFromKeyring,
|
||||
randomChatSecret,
|
||||
unwrapChatSecret,
|
||||
wrapChatSecret,
|
||||
} from "@tensamin/crypto/chatSecret";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const CHAT_SECRET_VERSION = 1;
|
||||
|
||||
function bytesFromProtocol(value: unknown): Uint8Array {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (Array.isArray(value)) return new Uint8Array(value);
|
||||
if (typeof value === "string") {
|
||||
const bin = atob(value);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
throw new Error("expected protocol bytes");
|
||||
}
|
||||
|
||||
function updateMessageStateBySendTime<
|
||||
T extends { SendTime: number; MessageState: RawMessage["MessageState"] },
|
||||
|
|
@ -50,14 +75,21 @@ function updateMessageStateBySendTime<
|
|||
|
||||
export default function Provider({ children }: { children: ReactNode }) {
|
||||
const { load } = useStorage();
|
||||
const { send, subscribePush, subscribeEncrypted, decryptEncryptedRecord } =
|
||||
useMTP();
|
||||
const { send, subscribePush } = useMTP();
|
||||
const { get: getUser } = useUser();
|
||||
const { moveUserIdToTop } = useSession();
|
||||
|
||||
const [error] = useState("");
|
||||
const [errorDescription] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [errorDescription, setErrorDescription] = useState("");
|
||||
|
||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
||||
userId: number;
|
||||
value: Uint8Array | null;
|
||||
}>({
|
||||
userId: 0,
|
||||
value: null,
|
||||
});
|
||||
|
||||
const inputBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -71,40 +103,132 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
return Number(rawId ?? 0);
|
||||
}, [locationSearch]);
|
||||
|
||||
const getMessages = useCallback(
|
||||
async (amount: number, offset: number) => {
|
||||
const response = await send("EncryptedMessagesGet", {
|
||||
Limit: amount,
|
||||
SenderUserId: String(userIdValue),
|
||||
});
|
||||
|
||||
if (response.type.startsWith("error")) {
|
||||
throw new Error(response.type);
|
||||
const currentChatSecret = useMemo(() => {
|
||||
if (currentChatSecretState.userId !== userIdValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encryptedMessages = (response.data.Messages ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
const decrypted = await Promise.all(
|
||||
encryptedMessages.slice(offset, offset + amount).map(async (record) => {
|
||||
const inner = await decryptEncryptedRecord(record);
|
||||
const data = inner.data as Record<string, unknown>;
|
||||
return {
|
||||
NotEncrypted: false,
|
||||
SendTime: Number(
|
||||
data.SendTime ?? record.CreatedAt ?? record.createdAt,
|
||||
),
|
||||
Content: String(data.Content ?? ""),
|
||||
SentBySelf:
|
||||
String(record.SenderUserId ?? record.senderUserId ?? "") ===
|
||||
String(await load("user_id")),
|
||||
MessageState: "received" as RawMessage["MessageState"],
|
||||
};
|
||||
}),
|
||||
);
|
||||
return currentChatSecretState.value;
|
||||
}, [currentChatSecretState, userIdValue]);
|
||||
|
||||
const sorted = [...decrypted].sort((a, b) => a.SendTime - b.SendTime);
|
||||
useEffect(() => {
|
||||
if (!userIdValue) return;
|
||||
|
||||
let active = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
setError("");
|
||||
setErrorDescription("");
|
||||
|
||||
const ownUserId = Number(await load("user_id"));
|
||||
const keyring = await load("mtp_keyring");
|
||||
const chatId = deriveChatId(ownUserId, userIdValue);
|
||||
const secretId = deriveChatSecretId(chatId);
|
||||
|
||||
const existing = await send("GetChatSecret", {
|
||||
UserId: String(ownUserId),
|
||||
ChatId: chatId,
|
||||
SecretId: secretId,
|
||||
});
|
||||
|
||||
if (!existing.type.startsWith("Error")) {
|
||||
const data = existing.data as Record<string, unknown>;
|
||||
const secret = await unwrapChatSecret({
|
||||
encryptedSecret: bytesFromProtocol(data.EncryptedSecret),
|
||||
kemCiphertext: bytesFromProtocol(data.KemCiphertext),
|
||||
keyring,
|
||||
chatId: String(data.ChatId),
|
||||
secretId: String(data.SecretId),
|
||||
version: Number(data.VersionNumber),
|
||||
wrappingScheme: String(data.WrappingScheme),
|
||||
});
|
||||
|
||||
if (active) {
|
||||
setCurrentChatSecretState({ userId: userIdValue, value: secret });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rawSecret = randomChatSecret();
|
||||
const ownWrapped = await wrapChatSecret({
|
||||
chatSecret: rawSecret,
|
||||
recipientKemPublicKey: ownKemPublicKeyFromKeyring(keyring),
|
||||
chatId,
|
||||
secretId,
|
||||
version: CHAT_SECRET_VERSION,
|
||||
});
|
||||
|
||||
await send("SetChatSecret", {
|
||||
UserId: String(ownUserId),
|
||||
ChatId: chatId,
|
||||
SecretId: secretId,
|
||||
VersionNumber: CHAT_SECRET_VERSION,
|
||||
EncryptedSecret: Array.from(ownWrapped.encryptedSecret),
|
||||
KemCiphertext: Array.from(ownWrapped.kemCiphertext),
|
||||
WrappingScheme: ownWrapped.wrappingScheme,
|
||||
CreatedAt: Date.now(),
|
||||
});
|
||||
|
||||
const peerUser = await getUser(userIdValue);
|
||||
const peerWrapped = await wrapChatSecret({
|
||||
chatSecret: rawSecret,
|
||||
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
|
||||
peerUser.PublicKey,
|
||||
),
|
||||
chatId,
|
||||
secretId,
|
||||
version: CHAT_SECRET_VERSION,
|
||||
});
|
||||
|
||||
await send("ChatSecretForward", {
|
||||
ChatId: chatId,
|
||||
SenderUserId: String(ownUserId),
|
||||
RecipientUserId: String(userIdValue),
|
||||
SecretId: secretId,
|
||||
VersionNumber: CHAT_SECRET_VERSION,
|
||||
EncryptedSecret: Array.from(peerWrapped.encryptedSecret),
|
||||
KemCiphertext: Array.from(peerWrapped.kemCiphertext),
|
||||
WrappingScheme: peerWrapped.wrappingScheme,
|
||||
CreatedAt: Date.now(),
|
||||
});
|
||||
|
||||
if (active) {
|
||||
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
|
||||
}
|
||||
} catch (err) {
|
||||
log(1, "chat", "red", "Failed to initialize chat secret", err);
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.name : "Unknown Error");
|
||||
setErrorDescription(err instanceof Error ? err.message : String(err));
|
||||
setCurrentChatSecretState({ userId: userIdValue, value: null });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [getUser, load, send, userIdValue]);
|
||||
|
||||
const getMessages = useCallback(
|
||||
async (amount: number, offset: number) => {
|
||||
if (!currentChatSecret) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = await send("MessagesGet", {
|
||||
Amount: amount,
|
||||
Offset: offset,
|
||||
UserId: userIdValue,
|
||||
});
|
||||
|
||||
if (messages.type.startsWith("error")) {
|
||||
throw new Error(messages.type);
|
||||
}
|
||||
|
||||
const rawMessages = messages.data.Messages;
|
||||
const sorted = [...rawMessages].sort((a, b) => a.SendTime - b.SendTime);
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const fetchedSendTimes = new Set(sorted.map((item) => item.SendTime));
|
||||
|
|
@ -118,9 +242,20 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
}
|
||||
|
||||
return sorted;
|
||||
return await Promise.all(
|
||||
sorted.map(async (message) => {
|
||||
try {
|
||||
return {
|
||||
...message,
|
||||
Content: await decryptChatText(currentChatSecret, message.Content),
|
||||
};
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
[send, userIdValue, decryptEncryptedRecord, load],
|
||||
[currentChatSecret, send, userIdValue],
|
||||
);
|
||||
|
||||
const addLiveMessage = useCallback(
|
||||
|
|
@ -161,33 +296,75 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
setLiveMessagesState([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeEncrypted("MessageSend", (data) => {
|
||||
const rawData = data as unknown as {
|
||||
Content?: unknown;
|
||||
ReceiverId?: unknown;
|
||||
SendTime?: unknown;
|
||||
};
|
||||
const sendTime = Number(rawData.SendTime);
|
||||
|
||||
if (!Number.isFinite(sendTime) || typeof rawData.Content !== "string") {
|
||||
log(3, "chat", "yellow", "Ignoring invalid encrypted live message");
|
||||
return;
|
||||
}
|
||||
|
||||
addLiveMessage({
|
||||
NotEncrypted: false,
|
||||
SendTime: sendTime,
|
||||
Content: rawData.Content,
|
||||
SentBySelf: false,
|
||||
MessageState: "received",
|
||||
});
|
||||
});
|
||||
}, [addLiveMessage, subscribeEncrypted]);
|
||||
|
||||
// Get live updates for message states
|
||||
useEffect(() => {
|
||||
return subscribePush((message) => {
|
||||
if (message.type === "ChatSecretForward") {
|
||||
const data = message.data as Record<string, unknown>;
|
||||
void (async () => {
|
||||
const ownUserId = Number(await load("user_id"));
|
||||
if (String(data.RecipientUserId) !== String(ownUserId)) return;
|
||||
|
||||
const keyring = await load("mtp_keyring");
|
||||
const chatId = String(data.ChatId);
|
||||
const secretId = String(data.SecretId);
|
||||
const secret = await unwrapChatSecret({
|
||||
encryptedSecret: bytesFromProtocol(data.EncryptedSecret),
|
||||
kemCiphertext: bytesFromProtocol(data.KemCiphertext),
|
||||
keyring,
|
||||
chatId,
|
||||
secretId,
|
||||
version: Number(data.VersionNumber),
|
||||
wrappingScheme: String(data.WrappingScheme),
|
||||
});
|
||||
const ownWrapped = await wrapChatSecret({
|
||||
chatSecret: secret,
|
||||
recipientKemPublicKey: ownKemPublicKeyFromKeyring(keyring),
|
||||
chatId,
|
||||
secretId,
|
||||
version: Number(data.VersionNumber),
|
||||
});
|
||||
|
||||
await send("SetChatSecret", {
|
||||
UserId: String(ownUserId),
|
||||
ChatId: chatId,
|
||||
SecretId: secretId,
|
||||
VersionNumber: Number(data.VersionNumber),
|
||||
EncryptedSecret: Array.from(ownWrapped.encryptedSecret),
|
||||
KemCiphertext: Array.from(ownWrapped.kemCiphertext),
|
||||
WrappingScheme: ownWrapped.wrappingScheme,
|
||||
CreatedAt: Date.now(),
|
||||
});
|
||||
|
||||
if (deriveChatId(ownUserId, userIdValue) === chatId) {
|
||||
setCurrentChatSecretState({ userId: userIdValue, value: secret });
|
||||
}
|
||||
})().catch((err) => {
|
||||
log(1, "chat", "red", "Failed to accept chat secret", err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MessageLive") {
|
||||
const data = message.data as {
|
||||
Message?: RawMessage;
|
||||
SenderId?: number;
|
||||
};
|
||||
if (!data.Message || !currentChatSecret) return;
|
||||
|
||||
void decryptChatText(currentChatSecret, data.Message.Content)
|
||||
.catch(() => data.Message?.Content ?? "")
|
||||
.then((content) => {
|
||||
if (!data.Message) return;
|
||||
addLiveMessage({
|
||||
...data.Message,
|
||||
Content: content,
|
||||
SentBySelf: false,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== "MessageState") {
|
||||
return;
|
||||
}
|
||||
|
|
@ -243,6 +420,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
const queryKey = [
|
||||
"chat-messages",
|
||||
String(userIdValue),
|
||||
currentChatSecret !== null,
|
||||
] as const;
|
||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||
queryKey,
|
||||
|
|
@ -278,7 +456,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
},
|
||||
);
|
||||
});
|
||||
}, [subscribePush, userIdValue]);
|
||||
}, [addLiveMessage, currentChatSecret, load, send, subscribePush, userIdValue]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
@ -288,7 +466,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
liveMessages: () => liveMessagesState,
|
||||
addLiveMessage,
|
||||
clearLiveMessages,
|
||||
sharedSecret: "",
|
||||
chatSecret: currentChatSecret,
|
||||
userId: userIdValue,
|
||||
inputBoxRef,
|
||||
error,
|
||||
|
|
@ -308,7 +486,7 @@ type contextType = {
|
|||
setFailed: (failed: boolean) => void;
|
||||
};
|
||||
clearLiveMessages: () => void;
|
||||
sharedSecret: string;
|
||||
chatSecret: Uint8Array | null;
|
||||
userId: number;
|
||||
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||
error: string;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export default function Screen() {
|
|||
liveMessages,
|
||||
clearLiveMessages,
|
||||
userId,
|
||||
chatSecret,
|
||||
error,
|
||||
errorDescription,
|
||||
} = useChat();
|
||||
|
|
@ -105,12 +106,13 @@ export default function Screen() {
|
|||
const [value, setValue] = React.useState("");
|
||||
|
||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||
const hasChatSecret = chatSecret !== null;
|
||||
|
||||
const messagesQuery = useInfiniteQuery({
|
||||
queryKey: ["chat-messages", String(userId)],
|
||||
queryKey: ["chat-messages", String(userId), hasChatSecret],
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
||||
enabled: hasValidChatUser,
|
||||
enabled: hasValidChatUser && hasChatSecret,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.length < PAGE_SIZE) {
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"type": "module",
|
||||
"exports": {
|
||||
"./context": "./src/context.tsx",
|
||||
"./encryptedDeviceSecret": "./src/encryptedDeviceSecret.ts"
|
||||
"./chatSecret": "./src/chatSecret.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
|
|
|
|||
143
packages/crypto/src/chatSecret.ts
Normal file
143
packages/crypto/src/chatSecret.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { crypto } from "mtp";
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const CHAT_SECRET_WRAPPING_SCHEME =
|
||||
"mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1";
|
||||
|
||||
const CHAT_SECRET_SALT = textEncoder.encode("tensamin-chat-secret-v1");
|
||||
const CHAT_MESSAGE_SALT = textEncoder.encode("tensamin-chat-message-v1");
|
||||
|
||||
export function deriveChatId(ownUserId: number, peerUserId: number): string {
|
||||
const ids = [ownUserId, peerUserId].sort((a, b) => a - b);
|
||||
return `${ids[0]}:${ids[1]}`;
|
||||
}
|
||||
|
||||
export function deriveChatSecretId(chatId: string): string {
|
||||
return `chat:${chatId}:main`;
|
||||
}
|
||||
|
||||
export function randomChatSecret(): Uint8Array {
|
||||
return globalThis.crypto.getRandomValues(new Uint8Array(32));
|
||||
}
|
||||
|
||||
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
||||
return crypto.keyringToKeys(keyring).kemPublicKey;
|
||||
}
|
||||
|
||||
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
||||
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey;
|
||||
}
|
||||
|
||||
export async function wrapChatSecret(args: {
|
||||
chatSecret: Uint8Array;
|
||||
recipientKemPublicKey: Uint8Array;
|
||||
chatId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
}): Promise<{
|
||||
encryptedSecret: Uint8Array;
|
||||
kemCiphertext: Uint8Array;
|
||||
wrappingScheme: string;
|
||||
}> {
|
||||
const enc = crypto.encapsulate(args.recipientKemPublicKey);
|
||||
try {
|
||||
const wrappingKey = deriveWrappingKey({
|
||||
sharedSecret: enc.shared_secret,
|
||||
chatId: args.chatId,
|
||||
secretId: args.secretId,
|
||||
version: args.version,
|
||||
});
|
||||
|
||||
try {
|
||||
return {
|
||||
encryptedSecret: await crypto.encrypt(wrappingKey, args.chatSecret),
|
||||
kemCiphertext: enc.ciphertext,
|
||||
wrappingScheme: CHAT_SECRET_WRAPPING_SCHEME,
|
||||
};
|
||||
} finally {
|
||||
wrappingKey.fill(0);
|
||||
}
|
||||
} finally {
|
||||
enc.shared_secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unwrapChatSecret(args: {
|
||||
encryptedSecret: Uint8Array;
|
||||
kemCiphertext: Uint8Array;
|
||||
keyring: string;
|
||||
chatId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
wrappingScheme: string;
|
||||
}): Promise<Uint8Array> {
|
||||
if (args.wrappingScheme !== CHAT_SECRET_WRAPPING_SCHEME) {
|
||||
throw new Error(`Unsupported chat secret wrapping scheme: ${args.wrappingScheme}`);
|
||||
}
|
||||
|
||||
const ownKeys = crypto.keyringToKeys(args.keyring);
|
||||
const sharedSecret = crypto.decapsulate(ownKeys.kemSecretKey, args.kemCiphertext);
|
||||
|
||||
try {
|
||||
const wrappingKey = deriveWrappingKey({
|
||||
sharedSecret,
|
||||
chatId: args.chatId,
|
||||
secretId: args.secretId,
|
||||
version: args.version,
|
||||
});
|
||||
|
||||
try {
|
||||
return await crypto.decrypt(wrappingKey, args.encryptedSecret);
|
||||
} finally {
|
||||
wrappingKey.fill(0);
|
||||
}
|
||||
} finally {
|
||||
sharedSecret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptChatText(
|
||||
chatSecret: Uint8Array,
|
||||
plaintext: string,
|
||||
): Promise<string> {
|
||||
const key = deriveMessageKey(chatSecret);
|
||||
try {
|
||||
return await crypto.encryptText(key, plaintext);
|
||||
} finally {
|
||||
key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptChatText(
|
||||
chatSecret: Uint8Array,
|
||||
ciphertext: string,
|
||||
): Promise<string> {
|
||||
const key = deriveMessageKey(chatSecret);
|
||||
try {
|
||||
return await crypto.decryptText(key, ciphertext);
|
||||
} finally {
|
||||
key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function deriveWrappingKey(args: {
|
||||
sharedSecret: Uint8Array;
|
||||
chatId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
}): Uint8Array {
|
||||
return crypto.deriveEncryptionKey(
|
||||
args.sharedSecret,
|
||||
CHAT_SECRET_SALT,
|
||||
textEncoder.encode(`${args.chatId}:${args.secretId}:${args.version}`),
|
||||
);
|
||||
}
|
||||
|
||||
function deriveMessageKey(chatSecret: Uint8Array): Uint8Array {
|
||||
return crypto.deriveEncryptionKey(
|
||||
chatSecret,
|
||||
CHAT_MESSAGE_SALT,
|
||||
textEncoder.encode("message-content"),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createContext, useContext } from "react";
|
||||
import { crypto } from "mtp";
|
||||
import { base64ToBytes, bytesToBase64, crypto } from "mtp";
|
||||
|
||||
type CryptoContextType = {
|
||||
decrypt: (
|
||||
|
|
@ -50,15 +50,64 @@ function ownedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|||
return out;
|
||||
}
|
||||
|
||||
function secretKeyFromString(secret: string): Uint8Array {
|
||||
return crypto.deriveEncryptionKey(
|
||||
base64ToBytes(secret),
|
||||
new Uint8Array(0),
|
||||
new TextEncoder().encode("tensamin:shared-secret-text"),
|
||||
);
|
||||
}
|
||||
|
||||
function compareBytes(a: Uint8Array, b: Uint8Array): number {
|
||||
const len = Math.min(a.byteLength, b.byteLength);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const diff = a[i] - b[i];
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
return a.byteLength - b.byteLength;
|
||||
}
|
||||
|
||||
async function getSharedSecret(
|
||||
ownPrivateKey: string,
|
||||
ownPublicKey: string,
|
||||
otherPublicKey: string,
|
||||
): Promise<string> {
|
||||
crypto.keyringToKeys(ownPrivateKey);
|
||||
|
||||
const ownKeys = crypto.publicKeyBundleToKeys(ownPublicKey);
|
||||
const otherKeys = crypto.publicKeyBundleToKeys(otherPublicKey);
|
||||
const publicKeys = [ownKeys.kemPublicKey, otherKeys.kemPublicKey].sort(
|
||||
compareBytes,
|
||||
);
|
||||
const input = new Uint8Array(
|
||||
publicKeys[0].byteLength + publicKeys[1].byteLength,
|
||||
);
|
||||
|
||||
input.set(publicKeys[0]);
|
||||
input.set(publicKeys[1], publicKeys[0].byteLength);
|
||||
|
||||
return bytesToBase64(
|
||||
crypto.deriveEncryptionKey(
|
||||
input,
|
||||
new Uint8Array(0),
|
||||
new TextEncoder().encode("tensamin:legacy-shared-secret"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
const actions = createCryptoActions(() => ({
|
||||
decrypt: async (secret, input) =>
|
||||
ownedBytes(await crypto.decrypt(secret, input)),
|
||||
decryptText: crypto.decryptText,
|
||||
ownedBytes(await crypto.decrypt(secretKeyFromString(secret), input)),
|
||||
decryptText: (secret, ciphertext) =>
|
||||
crypto.decryptText(secretKeyFromString(secret), ciphertext),
|
||||
encrypt: async (secret, input) =>
|
||||
ownedBytes(await crypto.encrypt(secret, input)),
|
||||
encryptText: crypto.encryptText,
|
||||
getSharedSecret: crypto.getSharedSecret,
|
||||
ownedBytes(await crypto.encrypt(secretKeyFromString(secret), input)),
|
||||
encryptText: (secret, plaintext) =>
|
||||
crypto.encryptText(secretKeyFromString(secret), plaintext),
|
||||
getSharedSecret,
|
||||
}));
|
||||
|
||||
return <context.Provider value={actions}>{props.children}</context.Provider>;
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME =
|
||||
"webcrypto-aes-gcm-hkdf-sha256-v1";
|
||||
|
||||
export async function wrapDeviceSecret(args: {
|
||||
rawSecret: Uint8Array;
|
||||
wrappingSecret: Uint8Array | string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
}): Promise<{
|
||||
encryptedSecret: Uint8Array;
|
||||
wrappingScheme: string;
|
||||
wrappingPublicKeyId?: string;
|
||||
}> {
|
||||
if (!args.rawSecret.length) {
|
||||
throw new Error("rawSecret must not be empty");
|
||||
}
|
||||
|
||||
const key = await deriveWrappingKey(args);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const aad = metadataAad(args);
|
||||
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: ownedBytes(iv), additionalData: ownedBytes(aad) },
|
||||
key,
|
||||
ownedBytes(args.rawSecret),
|
||||
),
|
||||
);
|
||||
|
||||
const encryptedSecret = new Uint8Array(iv.length + ciphertext.length);
|
||||
encryptedSecret.set(iv, 0);
|
||||
encryptedSecret.set(ciphertext, iv.length);
|
||||
|
||||
return {
|
||||
encryptedSecret,
|
||||
wrappingScheme: ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME,
|
||||
};
|
||||
}
|
||||
|
||||
export async function unwrapDeviceSecret(args: {
|
||||
encryptedSecret: Uint8Array;
|
||||
wrappingSecret: Uint8Array | string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
wrappingScheme: string;
|
||||
}): Promise<Uint8Array> {
|
||||
if (args.wrappingScheme !== ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME) {
|
||||
throw new Error(
|
||||
`Unsupported encrypted device secret wrapping scheme: ${args.wrappingScheme}`,
|
||||
);
|
||||
}
|
||||
if (args.encryptedSecret.length <= 12) {
|
||||
throw new Error("encryptedSecret is too short");
|
||||
}
|
||||
|
||||
const key = await deriveWrappingKey(args);
|
||||
const iv = args.encryptedSecret.slice(0, 12);
|
||||
const ciphertext = args.encryptedSecret.slice(12);
|
||||
const aad = metadataAad(args);
|
||||
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: ownedBytes(iv), additionalData: ownedBytes(aad) },
|
||||
key,
|
||||
ownedBytes(ciphertext),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function deriveWrappingKey(args: {
|
||||
wrappingSecret: Uint8Array | string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
}): Promise<CryptoKey> {
|
||||
const wrappingSecret =
|
||||
typeof args.wrappingSecret === "string"
|
||||
? textEncoder.encode(args.wrappingSecret)
|
||||
: args.wrappingSecret;
|
||||
|
||||
if (wrappingSecret.length < 16) {
|
||||
throw new Error("No secure wrapping key available");
|
||||
}
|
||||
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
ownedBytes(wrappingSecret),
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: ownedBytes(textEncoder.encode("tensamin-e2ee-device-secret-v1")),
|
||||
info: ownedBytes(metadataAad(args)),
|
||||
},
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
function ownedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const out = new Uint8Array(bytes.byteLength);
|
||||
out.set(bytes);
|
||||
return out;
|
||||
}
|
||||
|
||||
function metadataAad(args: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
}): Uint8Array {
|
||||
return textEncoder.encode(
|
||||
JSON.stringify({
|
||||
userId: args.userId,
|
||||
deviceId: args.deviceId,
|
||||
secretId: args.secretId,
|
||||
version: args.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -45,249 +45,6 @@ function base64ToUint8Array(b64: string) {
|
|||
return out;
|
||||
}
|
||||
|
||||
function bytesFromProtocol(value: unknown): Uint8Array {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (Array.isArray(value)) return new Uint8Array(value);
|
||||
if (typeof value === "string") return base64ToUint8Array(value);
|
||||
throw new Error("expected protocol bytes");
|
||||
}
|
||||
|
||||
type ParsedFrameLike = {
|
||||
id?: number;
|
||||
type: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
interface MTPSessionState {
|
||||
version: 1;
|
||||
conversationId: string;
|
||||
ownClientId: bigint;
|
||||
peerClientId: bigint;
|
||||
peerPublicKey: Uint8Array;
|
||||
sendChainKey: Uint8Array;
|
||||
recvChainKey: Uint8Array;
|
||||
sendCount: number;
|
||||
recvCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface MTPSessionStorage {
|
||||
getSession(conversationId: string): Promise<MTPSessionState | null>;
|
||||
setSession(state: MTPSessionState): Promise<void>;
|
||||
deleteSession(conversationId: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface EncryptedDeviceSecretRecord {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
encryptedSecret: Uint8Array;
|
||||
wrappingPublicKeyId?: string;
|
||||
wrappingScheme: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface MTPEncryptedDeviceSecretProvider {
|
||||
setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise<void>;
|
||||
getEncryptedDeviceSecret(query: {
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
secretId?: string;
|
||||
}): Promise<EncryptedDeviceSecretRecord | null>;
|
||||
}
|
||||
|
||||
function serializeSessionState(
|
||||
state: MTPSessionState,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...state,
|
||||
ownClientId: state.ownClientId.toString(),
|
||||
peerClientId: state.peerClientId.toString(),
|
||||
peerPublicKey: Array.from(state.peerPublicKey),
|
||||
sendChainKey: Array.from(state.sendChainKey),
|
||||
recvChainKey: Array.from(state.recvChainKey),
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeSessionState(value: unknown): MTPSessionState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (typeof raw.conversationId !== "string") return null;
|
||||
return {
|
||||
version: 1,
|
||||
conversationId: raw.conversationId,
|
||||
ownClientId: BigInt(String(raw.ownClientId)),
|
||||
peerClientId: BigInt(String(raw.peerClientId)),
|
||||
peerPublicKey: new Uint8Array(raw.peerPublicKey as number[]),
|
||||
sendChainKey: new Uint8Array(raw.sendChainKey as number[]),
|
||||
recvChainKey: new Uint8Array(raw.recvChainKey as number[]),
|
||||
sendCount: Number(raw.sendCount),
|
||||
recvCount: Number(raw.recvCount),
|
||||
createdAt: Number(raw.createdAt),
|
||||
updatedAt: Number(raw.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
class LocalMTPSessionStorage implements MTPSessionStorage {
|
||||
private readonly load: <K extends "e2ee_sessions">(
|
||||
key: K,
|
||||
) => Promise<Record<string, unknown> | undefined>;
|
||||
|
||||
private readonly save: <K extends "e2ee_sessions">(
|
||||
key: K,
|
||||
value: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
load: <K extends "e2ee_sessions">(
|
||||
key: K,
|
||||
) => Promise<Record<string, unknown> | undefined>,
|
||||
save: <K extends "e2ee_sessions">(
|
||||
key: K,
|
||||
value: Record<string, unknown>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
this.load = load;
|
||||
this.save = save;
|
||||
}
|
||||
|
||||
async getSession(conversationId: string): Promise<MTPSessionState | null> {
|
||||
const sessions = (await this.load("e2ee_sessions")) ?? {};
|
||||
return deserializeSessionState(sessions[conversationId]);
|
||||
}
|
||||
|
||||
async setSession(state: MTPSessionState): Promise<void> {
|
||||
const sessions = { ...((await this.load("e2ee_sessions")) ?? {}) };
|
||||
sessions[state.conversationId] = serializeSessionState(state);
|
||||
await this.save("e2ee_sessions", sessions);
|
||||
}
|
||||
|
||||
async deleteSession(conversationId: string): Promise<void> {
|
||||
const sessions = { ...((await this.load("e2ee_sessions")) ?? {}) };
|
||||
delete sessions[conversationId];
|
||||
await this.save("e2ee_sessions", sessions);
|
||||
}
|
||||
}
|
||||
|
||||
function frameField<T>(
|
||||
frame: ParsedFrameLike,
|
||||
pascal: string,
|
||||
camel: string,
|
||||
): T {
|
||||
const data = frame.data as Record<string, unknown>;
|
||||
return (data[pascal] ?? data[camel]) as T;
|
||||
}
|
||||
|
||||
type ConnectedMTPClient = Awaited<ReturnType<typeof MTPClient.create>> & {
|
||||
request(
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { responseType?: string },
|
||||
): Promise<ParsedFrameLike>;
|
||||
sendEncrypted(
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options: {
|
||||
recipientClientId?: bigint | number | string;
|
||||
recipientPublicKey?: string | Uint8Array | number[];
|
||||
senderUserId?: string;
|
||||
recipientUserId?: string;
|
||||
recipientDeviceId?: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
subscribeEncrypted(
|
||||
type: string,
|
||||
handler: (data: unknown, meta: ParsedFrameLike) => void,
|
||||
): () => void;
|
||||
decryptEncryptedRecord(
|
||||
frameData: Record<string, unknown>,
|
||||
): Promise<ParsedFrameLike>;
|
||||
};
|
||||
|
||||
class NetworkEncryptedDeviceSecretProvider implements MTPEncryptedDeviceSecretProvider {
|
||||
#client: ConnectedMTPClient | null = null;
|
||||
|
||||
attach(client: ConnectedMTPClient): void {
|
||||
this.#client = client;
|
||||
}
|
||||
|
||||
async setEncryptedDeviceSecret(
|
||||
record: EncryptedDeviceSecretRecord,
|
||||
): Promise<void> {
|
||||
if (!this.#client) {
|
||||
throw new Error("encrypted device secret provider is not attached");
|
||||
}
|
||||
if (!record.encryptedSecret?.length || !record.wrappingScheme) {
|
||||
throw new Error(
|
||||
"encrypted device secret record is missing ciphertext metadata",
|
||||
);
|
||||
}
|
||||
|
||||
await this.#client.request("SetEncryptedDeviceSecret", {
|
||||
UserId: record.userId,
|
||||
DeviceId: record.deviceId,
|
||||
SecretId: record.secretId,
|
||||
VersionNumber: record.version,
|
||||
EncryptedSecret: record.encryptedSecret,
|
||||
WrappingPublicKeyId: record.wrappingPublicKeyId,
|
||||
WrappingScheme: record.wrappingScheme,
|
||||
CreatedAt: record.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
async getEncryptedDeviceSecret(query: {
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
secretId?: string;
|
||||
}): Promise<EncryptedDeviceSecretRecord | null> {
|
||||
if (!this.#client) {
|
||||
throw new Error("encrypted device secret provider is not attached");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.#client.request(
|
||||
"GetEncryptedDeviceSecret",
|
||||
{
|
||||
UserId: query.userId,
|
||||
DeviceId: query.deviceId,
|
||||
SecretId: query.secretId,
|
||||
},
|
||||
{ responseType: "EncryptedDeviceSecretResponse" },
|
||||
);
|
||||
|
||||
if (response.type === "ErrorNotFound") return null;
|
||||
|
||||
return {
|
||||
userId: frameField(response, "UserId", "userId"),
|
||||
deviceId: frameField(response, "DeviceId", "deviceId"),
|
||||
secretId: frameField(response, "SecretId", "secretId"),
|
||||
version: Number(frameField(response, "VersionNumber", "versionNumber")),
|
||||
encryptedSecret: bytesFromProtocol(
|
||||
frameField(response, "EncryptedSecret", "encryptedSecret"),
|
||||
),
|
||||
wrappingPublicKeyId: frameField(
|
||||
response,
|
||||
"WrappingPublicKeyId",
|
||||
"wrappingPublicKeyId",
|
||||
),
|
||||
wrappingScheme: frameField(
|
||||
response,
|
||||
"WrappingScheme",
|
||||
"wrappingScheme",
|
||||
),
|
||||
createdAt: Number(frameField(response, "CreatedAt", "createdAt")),
|
||||
updatedAt: Number(frameField(response, "UpdatedAt", "updatedAt")),
|
||||
};
|
||||
} catch (error) {
|
||||
if (String(error).includes("ErrorNotFound")) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ProtocolMessage<
|
||||
T extends keyof Schemas & string = keyof Schemas & string,
|
||||
> = {
|
||||
|
|
@ -302,38 +59,15 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
|
|||
options?: { id?: number },
|
||||
) => Promise<ProtocolMessage<T>>;
|
||||
|
||||
export type BoundSendEncryptedFn = <T extends keyof Schemas & string>(
|
||||
type: T,
|
||||
data: z.infer<Schemas[T]["request"]>,
|
||||
options: {
|
||||
recipientClientId?: bigint | number | string;
|
||||
recipientPublicKey?: string | Uint8Array | number[];
|
||||
senderUserId?: string;
|
||||
recipientUserId?: string;
|
||||
recipientDeviceId?: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
|
||||
export type PushHandler = (message: ProtocolMessage) => void;
|
||||
|
||||
type ContextType = {
|
||||
send: BoundSendFn;
|
||||
sendEncrypted: BoundSendEncryptedFn;
|
||||
subscribe: <T extends keyof Schemas & string>(
|
||||
type: T,
|
||||
handler: (message: ProtocolMessage<T>) => void,
|
||||
) => () => void;
|
||||
subscribePush: (handler: PushHandler) => () => void;
|
||||
subscribeEncrypted: <T extends keyof Schemas & string>(
|
||||
type: T,
|
||||
handler: (
|
||||
data: z.infer<Schemas[T]["response"]>,
|
||||
meta: ProtocolMessage<T>,
|
||||
) => void,
|
||||
) => () => void;
|
||||
decryptEncryptedRecord: (
|
||||
frameData: Record<string, unknown>,
|
||||
) => Promise<ParsedFrameLike>;
|
||||
readyState: number;
|
||||
ownPing: number;
|
||||
iotaPing: number;
|
||||
|
|
@ -400,7 +134,7 @@ export function Provider(props: {
|
|||
children: ReactNode;
|
||||
blockConnection?: boolean;
|
||||
}) {
|
||||
const { load, save } = useStorage();
|
||||
const { load } = useStorage();
|
||||
|
||||
const [readyState, setReadyState] = useState<number>(
|
||||
ConnectionState.Disconnected,
|
||||
|
|
@ -415,7 +149,9 @@ export function Provider(props: {
|
|||
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||
|
||||
const clientRef = useRef<ConnectedMTPClient | null>(null);
|
||||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const connected = readyState === ConnectionState.Connected;
|
||||
|
||||
|
|
@ -444,57 +180,6 @@ export function Provider(props: {
|
|||
[],
|
||||
);
|
||||
|
||||
const sendEncrypted: BoundSendEncryptedFn = useMemo(
|
||||
() => async (type, data, options) => {
|
||||
const client = clientRef.current;
|
||||
|
||||
if (!client) {
|
||||
throw new Error("mtp is not connected");
|
||||
}
|
||||
|
||||
let recipientClientId = options.recipientClientId;
|
||||
let recipientPublicKey = options.recipientPublicKey;
|
||||
if (recipientClientId == null) {
|
||||
if (!options.recipientUserId) {
|
||||
throw new Error("recipientClientId or recipientUserId is required");
|
||||
}
|
||||
|
||||
const recipientUserId = Number(options.recipientUserId);
|
||||
if (!Number.isSafeInteger(recipientUserId) || recipientUserId <= 0) {
|
||||
throw new Error("recipientUserId must be a valid user id");
|
||||
}
|
||||
|
||||
const response = await client.request(
|
||||
"GetUserData",
|
||||
{ UserId: recipientUserId },
|
||||
{ responseType: "GetUserData" },
|
||||
);
|
||||
if (response.type === "ErrorNotFound") {
|
||||
throw new Error("Recipient user data not found");
|
||||
}
|
||||
|
||||
recipientPublicKey = frameField<string>(
|
||||
response,
|
||||
"PublicKey",
|
||||
"publicKey",
|
||||
);
|
||||
if (!recipientPublicKey) {
|
||||
throw new Error("Recipient has no encryption public key available");
|
||||
}
|
||||
recipientClientId = BigInt(recipientUserId);
|
||||
}
|
||||
|
||||
await client.sendEncrypted(type, data as Record<string, unknown>, {
|
||||
recipientClientId,
|
||||
recipientPublicKey,
|
||||
senderUserId: options.senderUserId,
|
||||
recipientUserId: options.recipientUserId,
|
||||
recipientDeviceId: options.recipientDeviceId,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
|
|
@ -506,34 +191,6 @@ export function Provider(props: {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const subscribeEncrypted = useCallback<ContextType["subscribeEncrypted"]>(
|
||||
(type, handler) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
return client.subscribeEncrypted(type, (data, meta) => {
|
||||
handler(data as z.infer<Schemas[typeof type]["response"]>, {
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
data: data as z.infer<Schemas[typeof type]["response"]>,
|
||||
});
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const decryptEncryptedRecord = useCallback<
|
||||
ContextType["decryptEncryptedRecord"]
|
||||
>(async (frameData) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) throw new Error("mtp is not connected");
|
||||
return client.decryptEncryptedRecord(frameData);
|
||||
}, []);
|
||||
|
||||
const decryptEncryptedRecordForQueue = decryptEncryptedRecord;
|
||||
|
||||
const subscribePush = useCallback((handler: PushHandler) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
|
|
@ -543,6 +200,7 @@ export function Provider(props: {
|
|||
const unsubscribers = [
|
||||
"MessageLive",
|
||||
"MessageState",
|
||||
"ChatSecretForward",
|
||||
"CallInvite",
|
||||
"ErrorNoIota",
|
||||
].map((type) =>
|
||||
|
|
@ -692,31 +350,16 @@ export function Provider(props: {
|
|||
|
||||
log(2, "mtp", "green", "Connecting to: " + url);
|
||||
|
||||
const encryptedDeviceSecretProvider =
|
||||
new NetworkEncryptedDeviceSecretProvider();
|
||||
|
||||
const createMTPClient = MTPClient.create as unknown as (
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<ConnectedMTPClient>;
|
||||
const client = await createMTPClient({
|
||||
const client = await MTPClient.create({
|
||||
url,
|
||||
credentials: {
|
||||
clientId: BigInt(userId),
|
||||
clientId: userId,
|
||||
keyring: base64ToUint8Array(await load("mtp_keyring")),
|
||||
},
|
||||
hostPublicKey: omikronPublicKey,
|
||||
descriptor: "client",
|
||||
pings: true,
|
||||
encryptedDeviceSecretProvider,
|
||||
sessionStorage: new LocalMTPSessionStorage(
|
||||
load as never,
|
||||
save as never,
|
||||
),
|
||||
logger: (event: {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
direction?: "send" | "recv";
|
||||
}) => {
|
||||
logger: (event) => {
|
||||
if (event.type === "state") {
|
||||
setReadyState(
|
||||
clientRef.current?.state ?? ConnectionState.Disconnected,
|
||||
|
|
@ -752,8 +395,6 @@ export function Provider(props: {
|
|||
return;
|
||||
}
|
||||
|
||||
encryptedDeviceSecretProvider.attach(client);
|
||||
|
||||
clientRef.current = client;
|
||||
setReadyState(client.state);
|
||||
await client.connect();
|
||||
|
|
@ -886,7 +527,7 @@ export function Provider(props: {
|
|||
setIdentifying(false);
|
||||
sonnerToast.dismiss("mtp-connection-toast");
|
||||
};
|
||||
}, [mtpUrl, props.blockConnection, load, save]);
|
||||
}, [mtpUrl, props.blockConnection, load]);
|
||||
|
||||
// No Iota check
|
||||
useEffect(() => {
|
||||
|
|
@ -920,11 +561,8 @@ export function Provider(props: {
|
|||
() =>
|
||||
createAsyncQueue<{
|
||||
send: typeof send;
|
||||
sendEncrypted: typeof sendEncrypted;
|
||||
subscribe: typeof subscribe;
|
||||
subscribePush: typeof subscribePush;
|
||||
subscribeEncrypted: typeof subscribeEncrypted;
|
||||
decryptEncryptedRecord: typeof decryptEncryptedRecordForQueue;
|
||||
}>(),
|
||||
[],
|
||||
);
|
||||
|
|
@ -932,11 +570,8 @@ export function Provider(props: {
|
|||
if (connected && identified && mtpUrl) {
|
||||
mtpRef.set({
|
||||
send,
|
||||
sendEncrypted,
|
||||
subscribe,
|
||||
subscribePush,
|
||||
subscribeEncrypted,
|
||||
decryptEncryptedRecord: decryptEncryptedRecordForQueue,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
|
|
@ -944,11 +579,8 @@ export function Provider(props: {
|
|||
identified,
|
||||
mtpUrl,
|
||||
send,
|
||||
sendEncrypted,
|
||||
subscribe,
|
||||
subscribePush,
|
||||
subscribeEncrypted,
|
||||
decryptEncryptedRecordForQueue,
|
||||
mtpRef,
|
||||
]);
|
||||
|
||||
|
|
@ -960,32 +592,12 @@ export function Provider(props: {
|
|||
[mtpRef],
|
||||
);
|
||||
|
||||
const sendEncryptedQueued: BoundSendEncryptedFn = useMemo(
|
||||
() => async (type, data, options) => {
|
||||
const mtp = await mtpRef.get();
|
||||
return mtp.sendEncrypted(type, data, options);
|
||||
},
|
||||
[mtpRef],
|
||||
);
|
||||
|
||||
const decryptEncryptedRecordQueued: ContextType["decryptEncryptedRecord"] =
|
||||
useMemo(
|
||||
() => async (frameData) => {
|
||||
const mtp = await mtpRef.get();
|
||||
return mtp.decryptEncryptedRecord(frameData);
|
||||
},
|
||||
[mtpRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<MTPContext.Provider
|
||||
value={{
|
||||
send: sendQueued,
|
||||
sendEncrypted: sendEncryptedQueued,
|
||||
subscribe,
|
||||
subscribePush,
|
||||
subscribeEncrypted,
|
||||
decryptEncryptedRecord: decryptEncryptedRecordQueued,
|
||||
readyState,
|
||||
ownPing,
|
||||
iotaPing,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,2 @@
|
|||
export { Provider, useMTP } from "./context";
|
||||
export type {
|
||||
BoundSendEncryptedFn,
|
||||
BoundSendFn,
|
||||
PushHandler,
|
||||
ProtocolMessage,
|
||||
} from "./context";
|
||||
export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context";
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ const bytesLike = z.union([
|
|||
z.array(z.number().int().min(0).max(255)),
|
||||
z.base64(),
|
||||
]);
|
||||
const clientIdLike = z.union([z.bigint(), z.number(), z.string()]);
|
||||
|
||||
export const Message = z.object({
|
||||
NotEncrypted: z.boolean().optional(),
|
||||
|
|
@ -252,95 +251,55 @@ export const mtp = {
|
|||
SenderId: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
SetEncryptedDeviceSecret: {
|
||||
SetChatSecret: {
|
||||
request: z.object({
|
||||
UserId: z.string(),
|
||||
DeviceId: z.string(),
|
||||
ChatId: z.string(),
|
||||
SecretId: z.string(),
|
||||
VersionNumber: z.number(),
|
||||
EncryptedSecret: bytesLike,
|
||||
WrappingPublicKeyId: z.string().optional(),
|
||||
KemCiphertext: bytesLike,
|
||||
WrappingScheme: z.string(),
|
||||
CreatedAt: z.number(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
GetEncryptedDeviceSecret: {
|
||||
GetChatSecret: {
|
||||
request: z.object({
|
||||
UserId: z.string(),
|
||||
DeviceId: z.string().optional(),
|
||||
ChatId: z.string(),
|
||||
SecretId: z.string().optional(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
EncryptedDeviceSecretResponse: {
|
||||
ChatSecretResponse: {
|
||||
request: z.object({}).optional(),
|
||||
response: z.object({
|
||||
UserId: z.string(),
|
||||
DeviceId: z.string(),
|
||||
ChatId: z.string(),
|
||||
SecretId: z.string(),
|
||||
VersionNumber: z.number(),
|
||||
EncryptedSecret: bytesLike,
|
||||
WrappingPublicKeyId: z.string().optional(),
|
||||
KemCiphertext: bytesLike,
|
||||
WrappingScheme: z.string(),
|
||||
CreatedAt: z.number(),
|
||||
UpdatedAt: z.number(),
|
||||
}),
|
||||
},
|
||||
EncryptedMessage: {
|
||||
ChatSecretForward: {
|
||||
request: z.object({
|
||||
MessageId: z.string(),
|
||||
ConversationId: z.string(),
|
||||
SenderClientId: clientIdLike,
|
||||
RecipientClientId: clientIdLike,
|
||||
SenderUserId: z.string().optional(),
|
||||
RecipientUserId: z.string().optional(),
|
||||
ChatId: z.string(),
|
||||
SenderUserId: z.string(),
|
||||
RecipientUserId: z.string(),
|
||||
SecretId: z.string(),
|
||||
VersionNumber: z.number(),
|
||||
EncryptedSecret: bytesLike,
|
||||
KemCiphertext: bytesLike,
|
||||
WrappingScheme: z.string(),
|
||||
CreatedAt: z.number(),
|
||||
EncryptionVersion: z.literal(1),
|
||||
EncryptedPayload: bytesLike,
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
EncryptedMessageAck: {
|
||||
request: z.object({
|
||||
MessageId: z.string(),
|
||||
ConversationId: z.string(),
|
||||
RecipientClientId: clientIdLike,
|
||||
SendTime: z.number().optional(),
|
||||
GetTime: z.number().optional(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
EncryptedMessagesGet: {
|
||||
request: z.object({
|
||||
ConversationId: z.string().optional(),
|
||||
PeerClientId: clientIdLike.optional(),
|
||||
SenderUserId: z.string().optional(),
|
||||
Since: z.number().optional(),
|
||||
Limit: z.number().optional(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
EncryptedMessagesResponse: {
|
||||
request: z.object({}).optional(),
|
||||
response: z.object({
|
||||
Messages: z.array(
|
||||
z.object({
|
||||
MessageId: z.string(),
|
||||
ConversationId: z.string(),
|
||||
SenderClientId: clientIdLike,
|
||||
RecipientClientId: clientIdLike,
|
||||
SenderUserId: z.string().optional(),
|
||||
RecipientUserId: z.string().optional(),
|
||||
CreatedAt: z.number(),
|
||||
EncryptionVersion: z.literal(1),
|
||||
EncryptedPayload: bytesLike,
|
||||
}),
|
||||
),
|
||||
HasMore: z.boolean().optional(),
|
||||
NextCursor: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
ErrorNoIota: {
|
||||
request: z.object({}).optional(),
|
||||
response: z.object({}),
|
||||
|
|
@ -354,8 +313,6 @@ export interface Storage extends SettingsStorageDefaults {
|
|||
session_id: number;
|
||||
user_id: number;
|
||||
mtp_keyring: string;
|
||||
e2ee_device_id: string;
|
||||
e2ee_sessions: Record<string, unknown>;
|
||||
ppandtos_done: boolean;
|
||||
accepted_terms_of_service: boolean;
|
||||
accepted_privacy_policy: boolean;
|
||||
|
|
@ -390,8 +347,6 @@ export const storageDefaults: Storage = {
|
|||
session_id: 0,
|
||||
user_id: 0,
|
||||
mtp_keyring: "",
|
||||
e2ee_device_id: "",
|
||||
e2ee_sessions: {},
|
||||
ppandtos_done: false,
|
||||
accepted_terms_of_service: false,
|
||||
accepted_privacy_policy: false,
|
||||
|
|
|
|||
|
|
@ -137,13 +137,10 @@ type_maps:
|
|||
AppChallengeResponse: 133
|
||||
AppIdentificationResponse: 134
|
||||
LoadTxtRecord: 135
|
||||
SetEncryptedDeviceSecret: 139
|
||||
GetEncryptedDeviceSecret: 140
|
||||
EncryptedDeviceSecretResponse: 141
|
||||
EncryptedMessage: 142
|
||||
EncryptedMessageAck: 143
|
||||
EncryptedMessagesGet: 144
|
||||
EncryptedMessagesResponse: 145
|
||||
SetChatSecret: 139
|
||||
GetChatSecret: 140
|
||||
ChatSecretResponse: 141
|
||||
ChatSecretForward: 142
|
||||
DataTypes:
|
||||
ErrorType: 32
|
||||
ErrorProtocol: 33
|
||||
|
|
@ -251,18 +248,9 @@ type_maps:
|
|||
SecretId: 142
|
||||
VersionNumber: 143
|
||||
EncryptedSecret: 144
|
||||
WrappingPublicKeyId: 145
|
||||
WrappingScheme: 146
|
||||
UpdatedAt: 147
|
||||
MessageId: 148
|
||||
ConversationId: 149
|
||||
SenderClientId: 150
|
||||
RecipientClientId: 151
|
||||
ChatId: 148
|
||||
KemCiphertext: 149
|
||||
SenderUserId: 152
|
||||
RecipientUserId: 153
|
||||
EncryptionVersion: 154
|
||||
HasMore: 155
|
||||
NextCursor: 156
|
||||
Since: 157
|
||||
Limit: 158
|
||||
PeerClientId: 159
|
||||
|
|
|
|||
Loading…
Reference in a new issue