(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 { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { cn, useIsMobile } from "@tensamin/ui";
|
import { cn, useIsMobile } from "@tensamin/ui";
|
||||||
|
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
|
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import GifPicker from "./gifPicker";
|
import GifPicker from "./gifPicker";
|
||||||
|
|
@ -28,8 +29,8 @@ export default function InputComponent({
|
||||||
}) {
|
}) {
|
||||||
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
||||||
|
|
||||||
const { sendEncrypted } = useMTP();
|
const { send } = useMTP();
|
||||||
const { addLiveMessage, userId, inputBoxRef } = useChat();
|
const { addLiveMessage, chatSecret, userId, inputBoxRef } = useChat();
|
||||||
const { load, save } = useStorage();
|
const { load, save } = useStorage();
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
const gifPopoverRef = React.useRef<HTMLDivElement>(null);
|
const gifPopoverRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -64,6 +65,11 @@ export default function InputComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!chatSecret) {
|
||||||
|
toast("error", "Still getting chat secret...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
log(3, "chat", "purple", "Message send init, adding live message ...");
|
log(3, "chat", "purple", "Message send init, adding live message ...");
|
||||||
|
|
||||||
const reference = addLiveMessage({
|
const reference = addLiveMessage({
|
||||||
|
|
@ -74,38 +80,33 @@ export default function InputComponent({
|
||||||
MessageState: "awaiting",
|
MessageState: "awaiting",
|
||||||
});
|
});
|
||||||
|
|
||||||
log(3, "chat", "purple", "Live message added, sending encrypted frame...");
|
log(3, "chat", "purple", "Live message added, encrypting...");
|
||||||
|
|
||||||
void load("user_id")
|
const encryptedContent = await encryptChatText(chatSecret, currentValue).catch(
|
||||||
.then((ownUserId) =>
|
(err) => {
|
||||||
sendEncrypted(
|
toast("error", "Failed to encrypt message", String(err));
|
||||||
"MessageSend",
|
reference.setFailed(true);
|
||||||
{
|
},
|
||||||
Content: currentValue,
|
);
|
||||||
|
|
||||||
|
if (!encryptedContent) return;
|
||||||
|
|
||||||
|
log(3, "chat", "purple", "Content encrypted, sending message...");
|
||||||
|
|
||||||
|
send("MessageSend", {
|
||||||
|
Content: encryptedContent,
|
||||||
ReceiverId: userId,
|
ReceiverId: userId,
|
||||||
SendTime: time,
|
SendTime: time,
|
||||||
},
|
}).catch((e) => {
|
||||||
{
|
log(0, "Chat", "red", "Failed to send message", e, {
|
||||||
senderUserId: String(ownUserId),
|
|
||||||
recipientUserId: String(userId),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
log(0, "Chat", "red", "Failed to send encrypted message", e, {
|
|
||||||
ReceiverId: userId,
|
ReceiverId: userId,
|
||||||
SendTime: time,
|
SendTime: time,
|
||||||
});
|
});
|
||||||
reference.setFailed(true);
|
reference.setFailed(true);
|
||||||
toast(
|
toast("error", "Failed to send message");
|
||||||
"error",
|
|
||||||
e instanceof Error && e.message.includes("public key")
|
|
||||||
? "Recipient has no encryption public key available"
|
|
||||||
: "Failed to send encrypted message",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log(3, "chat", "purple", "Encrypted message send queued");
|
log(3, "chat", "purple", "Message sent");
|
||||||
|
|
||||||
moveUserIdToTop(userId);
|
moveUserIdToTop(userId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,39 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useRouterState } from "@tanstack/react-router";
|
import { useRouterState } from "@tanstack/react-router";
|
||||||
import type { InfiniteData } from "@tanstack/react-query";
|
import type { InfiniteData } from "@tanstack/react-query";
|
||||||
import type { LiveMessage, RawMessage, RawMessages } from "./values";
|
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 { useStorage } from "@tensamin/storage/context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
|
import { useUser } from "@tensamin/user/context";
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
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<
|
function updateMessageStateBySendTime<
|
||||||
T extends { SendTime: number; MessageState: RawMessage["MessageState"] },
|
T extends { SendTime: number; MessageState: RawMessage["MessageState"] },
|
||||||
|
|
@ -50,14 +75,21 @@ function updateMessageStateBySendTime<
|
||||||
|
|
||||||
export default function Provider({ children }: { children: ReactNode }) {
|
export default function Provider({ children }: { children: ReactNode }) {
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { send, subscribePush, subscribeEncrypted, decryptEncryptedRecord } =
|
const { send, subscribePush } = useMTP();
|
||||||
useMTP();
|
const { get: getUser } = useUser();
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
|
|
||||||
const [error] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [errorDescription] = useState("");
|
const [errorDescription, setErrorDescription] = useState("");
|
||||||
|
|
||||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||||
|
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
||||||
|
userId: number;
|
||||||
|
value: Uint8Array | null;
|
||||||
|
}>({
|
||||||
|
userId: 0,
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
|
||||||
const inputBoxRef = useRef<HTMLDivElement>(null);
|
const inputBoxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -71,40 +103,132 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
return Number(rawId ?? 0);
|
return Number(rawId ?? 0);
|
||||||
}, [locationSearch]);
|
}, [locationSearch]);
|
||||||
|
|
||||||
const getMessages = useCallback(
|
const currentChatSecret = useMemo(() => {
|
||||||
async (amount: number, offset: number) => {
|
if (currentChatSecretState.userId !== userIdValue) {
|
||||||
const response = await send("EncryptedMessagesGet", {
|
return null;
|
||||||
Limit: amount,
|
|
||||||
SenderUserId: String(userIdValue),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.type.startsWith("error")) {
|
|
||||||
throw new Error(response.type);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedMessages = (response.data.Messages ?? []) as Record<
|
return currentChatSecretState.value;
|
||||||
string,
|
}, [currentChatSecretState, userIdValue]);
|
||||||
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"],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
if (sorted.length > 0) {
|
||||||
const fetchedSendTimes = new Set(sorted.map((item) => item.SendTime));
|
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(
|
const addLiveMessage = useCallback(
|
||||||
|
|
@ -161,33 +296,75 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
setLiveMessagesState([]);
|
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
|
// Get live updates for message states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribePush((message) => {
|
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") {
|
if (message.type !== "MessageState") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -243,6 +420,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
const queryKey = [
|
const queryKey = [
|
||||||
"chat-messages",
|
"chat-messages",
|
||||||
String(userIdValue),
|
String(userIdValue),
|
||||||
|
currentChatSecret !== null,
|
||||||
] as const;
|
] as const;
|
||||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||||
queryKey,
|
queryKey,
|
||||||
|
|
@ -278,7 +456,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [subscribePush, userIdValue]);
|
}, [addLiveMessage, currentChatSecret, load, send, subscribePush, userIdValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
@ -288,7 +466,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
liveMessages: () => liveMessagesState,
|
liveMessages: () => liveMessagesState,
|
||||||
addLiveMessage,
|
addLiveMessage,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
sharedSecret: "",
|
chatSecret: currentChatSecret,
|
||||||
userId: userIdValue,
|
userId: userIdValue,
|
||||||
inputBoxRef,
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
|
|
@ -308,7 +486,7 @@ type contextType = {
|
||||||
setFailed: (failed: boolean) => void;
|
setFailed: (failed: boolean) => void;
|
||||||
};
|
};
|
||||||
clearLiveMessages: () => void;
|
clearLiveMessages: () => void;
|
||||||
sharedSecret: string;
|
chatSecret: Uint8Array | null;
|
||||||
userId: number;
|
userId: number;
|
||||||
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||||
error: string;
|
error: string;
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export default function Screen() {
|
||||||
liveMessages,
|
liveMessages,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
userId,
|
userId,
|
||||||
|
chatSecret,
|
||||||
error,
|
error,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
@ -105,12 +106,13 @@ export default function Screen() {
|
||||||
const [value, setValue] = React.useState("");
|
const [value, setValue] = React.useState("");
|
||||||
|
|
||||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
|
const hasChatSecret = chatSecret !== null;
|
||||||
|
|
||||||
const messagesQuery = useInfiniteQuery({
|
const messagesQuery = useInfiniteQuery({
|
||||||
queryKey: ["chat-messages", String(userId)],
|
queryKey: ["chat-messages", String(userId), hasChatSecret],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
||||||
enabled: hasValidChatUser,
|
enabled: hasValidChatUser && hasChatSecret,
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
if (lastPage.length < PAGE_SIZE) {
|
if (lastPage.length < PAGE_SIZE) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./context": "./src/context.tsx",
|
"./context": "./src/context.tsx",
|
||||||
"./encryptedDeviceSecret": "./src/encryptedDeviceSecret.ts"
|
"./chatSecret": "./src/chatSecret.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"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 { createContext, useContext } from "react";
|
||||||
import { crypto } from "mtp";
|
import { base64ToBytes, bytesToBase64, crypto } from "mtp";
|
||||||
|
|
||||||
type CryptoContextType = {
|
type CryptoContextType = {
|
||||||
decrypt: (
|
decrypt: (
|
||||||
|
|
@ -50,15 +50,64 @@ function ownedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
return out;
|
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 }) {
|
export default function Provider(props: { children: React.ReactNode }) {
|
||||||
const actions = createCryptoActions(() => ({
|
const actions = createCryptoActions(() => ({
|
||||||
decrypt: async (secret, input) =>
|
decrypt: async (secret, input) =>
|
||||||
ownedBytes(await crypto.decrypt(secret, input)),
|
ownedBytes(await crypto.decrypt(secretKeyFromString(secret), input)),
|
||||||
decryptText: crypto.decryptText,
|
decryptText: (secret, ciphertext) =>
|
||||||
|
crypto.decryptText(secretKeyFromString(secret), ciphertext),
|
||||||
encrypt: async (secret, input) =>
|
encrypt: async (secret, input) =>
|
||||||
ownedBytes(await crypto.encrypt(secret, input)),
|
ownedBytes(await crypto.encrypt(secretKeyFromString(secret), input)),
|
||||||
encryptText: crypto.encryptText,
|
encryptText: (secret, plaintext) =>
|
||||||
getSharedSecret: crypto.getSharedSecret,
|
crypto.encryptText(secretKeyFromString(secret), plaintext),
|
||||||
|
getSharedSecret,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <context.Provider value={actions}>{props.children}</context.Provider>;
|
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;
|
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<
|
export type ProtocolMessage<
|
||||||
T extends keyof Schemas & string = keyof Schemas & string,
|
T extends keyof Schemas & string = keyof Schemas & string,
|
||||||
> = {
|
> = {
|
||||||
|
|
@ -302,38 +59,15 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
|
||||||
options?: { id?: number },
|
options?: { id?: number },
|
||||||
) => Promise<ProtocolMessage<T>>;
|
) => 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;
|
export type PushHandler = (message: ProtocolMessage) => void;
|
||||||
|
|
||||||
type ContextType = {
|
type ContextType = {
|
||||||
send: BoundSendFn;
|
send: BoundSendFn;
|
||||||
sendEncrypted: BoundSendEncryptedFn;
|
|
||||||
subscribe: <T extends keyof Schemas & string>(
|
subscribe: <T extends keyof Schemas & string>(
|
||||||
type: T,
|
type: T,
|
||||||
handler: (message: ProtocolMessage<T>) => void,
|
handler: (message: ProtocolMessage<T>) => void,
|
||||||
) => () => void;
|
) => () => void;
|
||||||
subscribePush: (handler: PushHandler) => () => 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;
|
readyState: number;
|
||||||
ownPing: number;
|
ownPing: number;
|
||||||
iotaPing: number;
|
iotaPing: number;
|
||||||
|
|
@ -400,7 +134,7 @@ export function Provider(props: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
blockConnection?: boolean;
|
blockConnection?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { load, save } = useStorage();
|
const { load } = useStorage();
|
||||||
|
|
||||||
const [readyState, setReadyState] = useState<number>(
|
const [readyState, setReadyState] = useState<number>(
|
||||||
ConnectionState.Disconnected,
|
ConnectionState.Disconnected,
|
||||||
|
|
@ -415,7 +149,9 @@ export function Provider(props: {
|
||||||
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
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;
|
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 subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
if (!client) {
|
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 subscribePush = useCallback((handler: PushHandler) => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|
@ -543,6 +200,7 @@ export function Provider(props: {
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
"MessageLive",
|
"MessageLive",
|
||||||
"MessageState",
|
"MessageState",
|
||||||
|
"ChatSecretForward",
|
||||||
"CallInvite",
|
"CallInvite",
|
||||||
"ErrorNoIota",
|
"ErrorNoIota",
|
||||||
].map((type) =>
|
].map((type) =>
|
||||||
|
|
@ -692,31 +350,16 @@ export function Provider(props: {
|
||||||
|
|
||||||
log(2, "mtp", "green", "Connecting to: " + url);
|
log(2, "mtp", "green", "Connecting to: " + url);
|
||||||
|
|
||||||
const encryptedDeviceSecretProvider =
|
const client = await MTPClient.create({
|
||||||
new NetworkEncryptedDeviceSecretProvider();
|
|
||||||
|
|
||||||
const createMTPClient = MTPClient.create as unknown as (
|
|
||||||
options: Record<string, unknown>,
|
|
||||||
) => Promise<ConnectedMTPClient>;
|
|
||||||
const client = await createMTPClient({
|
|
||||||
url,
|
url,
|
||||||
credentials: {
|
credentials: {
|
||||||
clientId: BigInt(userId),
|
clientId: userId,
|
||||||
keyring: base64ToUint8Array(await load("mtp_keyring")),
|
keyring: base64ToUint8Array(await load("mtp_keyring")),
|
||||||
},
|
},
|
||||||
hostPublicKey: omikronPublicKey,
|
hostPublicKey: omikronPublicKey,
|
||||||
descriptor: "client",
|
descriptor: "client",
|
||||||
pings: true,
|
pings: true,
|
||||||
encryptedDeviceSecretProvider,
|
logger: (event) => {
|
||||||
sessionStorage: new LocalMTPSessionStorage(
|
|
||||||
load as never,
|
|
||||||
save as never,
|
|
||||||
),
|
|
||||||
logger: (event: {
|
|
||||||
type: string;
|
|
||||||
data?: unknown;
|
|
||||||
direction?: "send" | "recv";
|
|
||||||
}) => {
|
|
||||||
if (event.type === "state") {
|
if (event.type === "state") {
|
||||||
setReadyState(
|
setReadyState(
|
||||||
clientRef.current?.state ?? ConnectionState.Disconnected,
|
clientRef.current?.state ?? ConnectionState.Disconnected,
|
||||||
|
|
@ -752,8 +395,6 @@ export function Provider(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptedDeviceSecretProvider.attach(client);
|
|
||||||
|
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
setReadyState(client.state);
|
setReadyState(client.state);
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
@ -886,7 +527,7 @@ export function Provider(props: {
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
sonnerToast.dismiss("mtp-connection-toast");
|
sonnerToast.dismiss("mtp-connection-toast");
|
||||||
};
|
};
|
||||||
}, [mtpUrl, props.blockConnection, load, save]);
|
}, [mtpUrl, props.blockConnection, load]);
|
||||||
|
|
||||||
// No Iota check
|
// No Iota check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -920,11 +561,8 @@ export function Provider(props: {
|
||||||
() =>
|
() =>
|
||||||
createAsyncQueue<{
|
createAsyncQueue<{
|
||||||
send: typeof send;
|
send: typeof send;
|
||||||
sendEncrypted: typeof sendEncrypted;
|
|
||||||
subscribe: typeof subscribe;
|
subscribe: typeof subscribe;
|
||||||
subscribePush: typeof subscribePush;
|
subscribePush: typeof subscribePush;
|
||||||
subscribeEncrypted: typeof subscribeEncrypted;
|
|
||||||
decryptEncryptedRecord: typeof decryptEncryptedRecordForQueue;
|
|
||||||
}>(),
|
}>(),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
@ -932,11 +570,8 @@ export function Provider(props: {
|
||||||
if (connected && identified && mtpUrl) {
|
if (connected && identified && mtpUrl) {
|
||||||
mtpRef.set({
|
mtpRef.set({
|
||||||
send,
|
send,
|
||||||
sendEncrypted,
|
|
||||||
subscribe,
|
subscribe,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
subscribeEncrypted,
|
|
||||||
decryptEncryptedRecord: decryptEncryptedRecordForQueue,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -944,11 +579,8 @@ export function Provider(props: {
|
||||||
identified,
|
identified,
|
||||||
mtpUrl,
|
mtpUrl,
|
||||||
send,
|
send,
|
||||||
sendEncrypted,
|
|
||||||
subscribe,
|
subscribe,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
subscribeEncrypted,
|
|
||||||
decryptEncryptedRecordForQueue,
|
|
||||||
mtpRef,
|
mtpRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -960,32 +592,12 @@ export function Provider(props: {
|
||||||
[mtpRef],
|
[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 (
|
return (
|
||||||
<MTPContext.Provider
|
<MTPContext.Provider
|
||||||
value={{
|
value={{
|
||||||
send: sendQueued,
|
send: sendQueued,
|
||||||
sendEncrypted: sendEncryptedQueued,
|
|
||||||
subscribe,
|
subscribe,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
subscribeEncrypted,
|
|
||||||
decryptEncryptedRecord: decryptEncryptedRecordQueued,
|
|
||||||
readyState,
|
readyState,
|
||||||
ownPing,
|
ownPing,
|
||||||
iotaPing,
|
iotaPing,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,2 @@
|
||||||
export { Provider, useMTP } from "./context";
|
export { Provider, useMTP } from "./context";
|
||||||
export type {
|
export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context";
|
||||||
BoundSendEncryptedFn,
|
|
||||||
BoundSendFn,
|
|
||||||
PushHandler,
|
|
||||||
ProtocolMessage,
|
|
||||||
} from "./context";
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ const bytesLike = z.union([
|
||||||
z.array(z.number().int().min(0).max(255)),
|
z.array(z.number().int().min(0).max(255)),
|
||||||
z.base64(),
|
z.base64(),
|
||||||
]);
|
]);
|
||||||
const clientIdLike = z.union([z.bigint(), z.number(), z.string()]);
|
|
||||||
|
|
||||||
export const Message = z.object({
|
export const Message = z.object({
|
||||||
NotEncrypted: z.boolean().optional(),
|
NotEncrypted: z.boolean().optional(),
|
||||||
|
|
@ -252,95 +251,55 @@ export const mtp = {
|
||||||
SenderId: z.number().optional(),
|
SenderId: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
SetEncryptedDeviceSecret: {
|
SetChatSecret: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
UserId: z.string(),
|
UserId: z.string(),
|
||||||
DeviceId: z.string(),
|
ChatId: z.string(),
|
||||||
SecretId: z.string(),
|
SecretId: z.string(),
|
||||||
VersionNumber: z.number(),
|
VersionNumber: z.number(),
|
||||||
EncryptedSecret: bytesLike,
|
EncryptedSecret: bytesLike,
|
||||||
WrappingPublicKeyId: z.string().optional(),
|
KemCiphertext: bytesLike,
|
||||||
WrappingScheme: z.string(),
|
WrappingScheme: z.string(),
|
||||||
CreatedAt: z.number(),
|
CreatedAt: z.number(),
|
||||||
}),
|
}),
|
||||||
response: z.object({}),
|
response: z.object({}),
|
||||||
},
|
},
|
||||||
GetEncryptedDeviceSecret: {
|
GetChatSecret: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
UserId: z.string(),
|
UserId: z.string(),
|
||||||
DeviceId: z.string().optional(),
|
ChatId: z.string(),
|
||||||
SecretId: z.string().optional(),
|
SecretId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
response: z.object({}),
|
response: z.object({}),
|
||||||
},
|
},
|
||||||
EncryptedDeviceSecretResponse: {
|
ChatSecretResponse: {
|
||||||
request: z.object({}).optional(),
|
request: z.object({}).optional(),
|
||||||
response: z.object({
|
response: z.object({
|
||||||
UserId: z.string(),
|
UserId: z.string(),
|
||||||
DeviceId: z.string(),
|
ChatId: z.string(),
|
||||||
SecretId: z.string(),
|
SecretId: z.string(),
|
||||||
VersionNumber: z.number(),
|
VersionNumber: z.number(),
|
||||||
EncryptedSecret: bytesLike,
|
EncryptedSecret: bytesLike,
|
||||||
WrappingPublicKeyId: z.string().optional(),
|
KemCiphertext: bytesLike,
|
||||||
WrappingScheme: z.string(),
|
WrappingScheme: z.string(),
|
||||||
CreatedAt: z.number(),
|
CreatedAt: z.number(),
|
||||||
UpdatedAt: z.number(),
|
UpdatedAt: z.number(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
EncryptedMessage: {
|
ChatSecretForward: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
MessageId: z.string(),
|
ChatId: z.string(),
|
||||||
ConversationId: z.string(),
|
SenderUserId: z.string(),
|
||||||
SenderClientId: clientIdLike,
|
RecipientUserId: z.string(),
|
||||||
RecipientClientId: clientIdLike,
|
SecretId: z.string(),
|
||||||
SenderUserId: z.string().optional(),
|
VersionNumber: z.number(),
|
||||||
RecipientUserId: z.string().optional(),
|
EncryptedSecret: bytesLike,
|
||||||
|
KemCiphertext: bytesLike,
|
||||||
|
WrappingScheme: z.string(),
|
||||||
CreatedAt: z.number(),
|
CreatedAt: z.number(),
|
||||||
EncryptionVersion: z.literal(1),
|
|
||||||
EncryptedPayload: bytesLike,
|
|
||||||
}),
|
}),
|
||||||
response: z.object({}),
|
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: {
|
ErrorNoIota: {
|
||||||
request: z.object({}).optional(),
|
request: z.object({}).optional(),
|
||||||
response: z.object({}),
|
response: z.object({}),
|
||||||
|
|
@ -354,8 +313,6 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
session_id: number;
|
session_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
mtp_keyring: string;
|
mtp_keyring: string;
|
||||||
e2ee_device_id: string;
|
|
||||||
e2ee_sessions: Record<string, unknown>;
|
|
||||||
ppandtos_done: boolean;
|
ppandtos_done: boolean;
|
||||||
accepted_terms_of_service: boolean;
|
accepted_terms_of_service: boolean;
|
||||||
accepted_privacy_policy: boolean;
|
accepted_privacy_policy: boolean;
|
||||||
|
|
@ -390,8 +347,6 @@ export const storageDefaults: Storage = {
|
||||||
session_id: 0,
|
session_id: 0,
|
||||||
user_id: 0,
|
user_id: 0,
|
||||||
mtp_keyring: "",
|
mtp_keyring: "",
|
||||||
e2ee_device_id: "",
|
|
||||||
e2ee_sessions: {},
|
|
||||||
ppandtos_done: false,
|
ppandtos_done: false,
|
||||||
accepted_terms_of_service: false,
|
accepted_terms_of_service: false,
|
||||||
accepted_privacy_policy: false,
|
accepted_privacy_policy: false,
|
||||||
|
|
|
||||||
|
|
@ -137,13 +137,10 @@ type_maps:
|
||||||
AppChallengeResponse: 133
|
AppChallengeResponse: 133
|
||||||
AppIdentificationResponse: 134
|
AppIdentificationResponse: 134
|
||||||
LoadTxtRecord: 135
|
LoadTxtRecord: 135
|
||||||
SetEncryptedDeviceSecret: 139
|
SetChatSecret: 139
|
||||||
GetEncryptedDeviceSecret: 140
|
GetChatSecret: 140
|
||||||
EncryptedDeviceSecretResponse: 141
|
ChatSecretResponse: 141
|
||||||
EncryptedMessage: 142
|
ChatSecretForward: 142
|
||||||
EncryptedMessageAck: 143
|
|
||||||
EncryptedMessagesGet: 144
|
|
||||||
EncryptedMessagesResponse: 145
|
|
||||||
DataTypes:
|
DataTypes:
|
||||||
ErrorType: 32
|
ErrorType: 32
|
||||||
ErrorProtocol: 33
|
ErrorProtocol: 33
|
||||||
|
|
@ -251,18 +248,9 @@ type_maps:
|
||||||
SecretId: 142
|
SecretId: 142
|
||||||
VersionNumber: 143
|
VersionNumber: 143
|
||||||
EncryptedSecret: 144
|
EncryptedSecret: 144
|
||||||
WrappingPublicKeyId: 145
|
|
||||||
WrappingScheme: 146
|
WrappingScheme: 146
|
||||||
UpdatedAt: 147
|
UpdatedAt: 147
|
||||||
MessageId: 148
|
ChatId: 148
|
||||||
ConversationId: 149
|
KemCiphertext: 149
|
||||||
SenderClientId: 150
|
|
||||||
RecipientClientId: 151
|
|
||||||
SenderUserId: 152
|
SenderUserId: 152
|
||||||
RecipientUserId: 153
|
RecipientUserId: 153
|
||||||
EncryptionVersion: 154
|
|
||||||
HasMore: 155
|
|
||||||
NextCursor: 156
|
|
||||||
Since: 157
|
|
||||||
Limit: 158
|
|
||||||
PeerClientId: 159
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue