TTP -> MTP, A lot of other stuff #21

Merged
alois merged 81 commits from dev into main 2026-07-21 11:57:33 +03:00
11 changed files with 504 additions and 715 deletions
Showing only changes of commit 2777ba34ca - Show all commits

(feat): more crypto migration
Some checks failed
/ build-web (push) Failing after 5m33s
/ build-desktop (linux) (push) Failing after 5m46s
/ build-mobile (push) Failing after 7m58s
/ release (push) Has been skipped

Alois 2026-07-06 00:13:26 +02:00

View file

@ -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",
{
Content: currentValue,
ReceiverId: userId,
SendTime: time,
},
{
senderUserId: String(ownUserId),
recipientUserId: String(userId),
},
),
)
.catch((e) => {
log(0, "Chat", "red", "Failed to send encrypted message", e, {
ReceiverId: userId,
SendTime: time,
});
reference.setFailed(true); 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",
);
});
log(3, "chat", "purple", "Encrypted message send queued"); if (!encryptedContent) return;
log(3, "chat", "purple", "Content encrypted, sending message...");
send("MessageSend", {
Content: encryptedContent,
ReceiverId: userId,
SendTime: time,
}).catch((e) => {
log(0, "Chat", "red", "Failed to send message", e, {
ReceiverId: userId,
SendTime: time,
});
reference.setFailed(true);
toast("error", "Failed to send message");
});
log(3, "chat", "purple", "Message sent");
moveUserIdToTop(userId); moveUserIdToTop(userId);

View file

@ -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 currentChatSecret = useMemo(() => {
if (currentChatSecretState.userId !== userIdValue) {
return null;
}
return currentChatSecretState.value;
}, [currentChatSecretState, userIdValue]);
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( const getMessages = useCallback(
async (amount: number, offset: number) => { async (amount: number, offset: number) => {
const response = await send("EncryptedMessagesGet", { if (!currentChatSecret) {
Limit: amount, return [];
SenderUserId: String(userIdValue),
});
if (response.type.startsWith("error")) {
throw new Error(response.type);
} }
const encryptedMessages = (response.data.Messages ?? []) as Record< const messages = await send("MessagesGet", {
string, Amount: amount,
unknown Offset: offset,
>[]; UserId: userIdValue,
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); 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;

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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