(feat): finish chat crypto migration
Some checks failed
/ build-web (push) Failing after 4m36s
/ build-desktop (linux) (push) Failing after 4m47s
/ build-mobile (push) Failing after 6m34s
/ release (push) Has been skipped

(wip): prep for full ECDH repo migration
This commit is contained in:
Alois 2026-07-06 19:23:37 +02:00
commit 2cd326d0b1
10 changed files with 195 additions and 339 deletions

View file

@ -7,7 +7,8 @@
"./context": "./src/context.tsx",
"./screen": "./src/screen.tsx",
"./behaviour-conversation": "./src/behaviour/conversation.ts",
"./behaviour-community": "./src/behaviour/community.ts"
"./behaviour-community": "./src/behaviour/community.ts",
"./values": "./src/values.ts"
},
"scripts": {
"format": "pnpm exec prettier --write .",

View file

@ -84,6 +84,8 @@ function MessageComponent({
}
}, [load, message.SendTime, user?.UserId, send]);
useEffect(() => {
if (message.SentBySelf) return;
let cancelled = false;
async function run() {
@ -111,7 +113,7 @@ function MessageComponent({
return () => {
cancelled = true;
};
}, [message.MessageState, load, messageStateReadUpdate]);
}, [message.MessageState, load, messageStateReadUpdate, message.SentBySelf]);
// Check if message is a url
const [isValidURL, setIsValidURL] = useState(false);

View file

@ -134,6 +134,16 @@ export default function Provider({ children }: { children: ReactNode }) {
return currentChatSecretState.value;
}, [currentChatSecretState, userIdValue]);
// Chat secret debugging
useEffect(() => {
if (!currentChatSecret) return;
const hex = Array.from(currentChatSecret, (b) =>
b.toString(16).padStart(2, "0"),
).join("");
log(3, "chat", "yellow", "Chat secret (sensitive)", hex);
}, [currentChatSecret]);
useEffect(() => {
if (!userIdValue) return;
@ -149,34 +159,6 @@ export default function Provider({ children }: { children: ReactNode }) {
const chatId = deriveChatId(ownUserId, userIdValue);
const secretId = deriveChatSecretId(chatId);
async function forwardChatSecret(chatSecret: Uint8Array) {
const peerUser = await getUser(userIdValue);
const peerWrapped = await wrapChatSecret({
chatSecret,
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
peerUser.PublicKey,
),
chatId,
secretId,
version: CHAT_SECRET_VERSION,
});
assertProtocolSuccess(
"ChatSecretForward",
await send("ChatSecretForward", {
ChatId: chatId,
SenderUserId: String(ownUserId),
RecipientUserId: String(userIdValue),
SecretId: secretId,
VersionNumber: CHAT_SECRET_VERSION,
EncryptedSecret: protocolBytes(peerWrapped.encryptedSecret),
KemCiphertext: protocolBytes(peerWrapped.kemCiphertext),
WrappingScheme: peerWrapped.wrappingScheme,
CreatedAt: Date.now(),
}),
);
}
const existing = await send("GetChatSecret", {
UserId: String(ownUserId),
ChatId: chatId,
@ -198,11 +180,13 @@ export default function Provider({ children }: { children: ReactNode }) {
if (active) {
setCurrentChatSecretState({ userId: userIdValue, value: secret });
}
await forwardChatSecret(secret);
return;
}
if (existing.type !== "ErrorNotSet") {
throw new Error(`GetChatSecret failed: ${existing.type}`);
}
const rawSecret = randomChatSecret();
const ownWrapped = await wrapChatSecret({
chatSecret: rawSecret,
@ -211,23 +195,40 @@ export default function Provider({ children }: { children: ReactNode }) {
secretId,
version: CHAT_SECRET_VERSION,
});
const peerUser = await getUser(userIdValue);
const peerWrapped = await wrapChatSecret({
chatSecret: rawSecret,
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
peerUser.PublicKey,
),
chatId,
secretId,
version: CHAT_SECRET_VERSION,
});
assertProtocolSuccess(
"SetChatSecret",
await send("SetChatSecret", {
UserId: String(ownUserId),
ChatId: chatId,
SecretId: secretId,
VersionNumber: CHAT_SECRET_VERSION,
EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret),
KemCiphertext: protocolBytes(ownWrapped.kemCiphertext),
WrappingScheme: ownWrapped.wrappingScheme,
CreatedAt: Date.now(),
Recipients: [
{
UserId: String(ownUserId),
EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret),
KemCiphertext: protocolBytes(ownWrapped.kemCiphertext),
},
{
UserId: String(userIdValue),
EncryptedSecret: protocolBytes(peerWrapped.encryptedSecret),
KemCiphertext: protocolBytes(peerWrapped.kemCiphertext),
},
],
}),
);
await forwardChatSecret(rawSecret);
if (active) {
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
}
@ -246,6 +247,45 @@ export default function Provider({ children }: { children: ReactNode }) {
};
}, [getUser, load, send, userIdValue]);
const getChatSecret = useCallback(
async (userId: number): Promise<Uint8Array | null> => {
if (!Number.isSafeInteger(userId) || userId <= 0) {
throw new Error("Invalid chat user id");
}
const ownUserId = Number(await load("user_id"));
const keyring = await load("mtp_keyring");
const chatId = deriveChatId(ownUserId, userId);
const secretId = deriveChatSecretId(chatId);
const response = await send("GetChatSecret", {
UserId: String(ownUserId),
ChatId: chatId,
SecretId: secretId,
});
if (response.type === "ErrorNotSet") {
return null;
}
if (response.type.startsWith("Error")) {
throw new Error(`GetChatSecret failed: ${response.type}`);
}
const data = response.data as Record<string, unknown>;
return 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),
});
},
[load, send],
);
const getMessages = useCallback(
async (amount: number, offset: number) => {
if (!currentChatSecret) {
@ -344,149 +384,7 @@ export default function Provider({ children }: { children: ReactNode }) {
// Get live updates for message states
useEffect(() => {
return subscribePush((message) => {
if (message.type === "ChatSecretForward") {
const data = message.data as Record<string, unknown>;
void (async () => {
const ownUserId = Number(await load("user_id"));
if (String(data.RecipientUserId) !== String(ownUserId)) return;
const keyring = await load("mtp_keyring");
const chatId = String(data.ChatId);
const secretId = String(data.SecretId);
const senderUserId = Number(data.SenderUserId);
if (!Number.isSafeInteger(senderUserId) || senderUserId <= 0) {
throw new Error("Invalid chat secret sender id");
}
const existing = await send("GetChatSecret", {
UserId: String(ownUserId),
ChatId: chatId,
SecretId: secretId,
});
if (!existing.type.startsWith("Error") && senderUserId > ownUserId) {
const existingData = existing.data as Record<string, unknown>;
const existingSecret = await unwrapChatSecret({
encryptedSecret: bytesFromProtocol(existingData.EncryptedSecret),
kemCiphertext: bytesFromProtocol(existingData.KemCiphertext),
keyring,
chatId: String(existingData.ChatId),
secretId: String(existingData.SecretId),
version: Number(existingData.VersionNumber),
wrappingScheme: String(existingData.WrappingScheme),
});
const senderUser = await getUser(senderUserId);
const senderWrapped = await wrapChatSecret({
chatSecret: existingSecret,
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
senderUser.PublicKey,
),
chatId,
secretId,
version: Number(existingData.VersionNumber),
});
assertProtocolSuccess(
"ChatSecretForward",
await send("ChatSecretForward", {
ChatId: chatId,
SenderUserId: String(ownUserId),
RecipientUserId: String(senderUserId),
SecretId: String(existingData.SecretId),
VersionNumber: Number(existingData.VersionNumber),
EncryptedSecret: protocolBytes(senderWrapped.encryptedSecret),
KemCiphertext: protocolBytes(senderWrapped.kemCiphertext),
WrappingScheme: senderWrapped.wrappingScheme,
CreatedAt: Date.now(),
}),
);
log(
2,
"chat",
"purple",
"Ignoring forwarded chat secret because local user owns the deterministic secret",
{
chatId,
ownUserId,
senderUserId,
},
);
return;
}
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),
});
assertProtocolSuccess(
"SetChatSecret",
await send("SetChatSecret", {
UserId: String(ownUserId),
ChatId: chatId,
SecretId: secretId,
VersionNumber: Number(data.VersionNumber),
EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret),
KemCiphertext: protocolBytes(ownWrapped.kemCiphertext),
WrappingScheme: ownWrapped.wrappingScheme,
CreatedAt: Date.now(),
}),
);
if (deriveChatId(ownUserId, userIdValue) === chatId) {
setCurrentChatSecretState({ userId: userIdValue, value: secret });
void queryClient.invalidateQueries({
queryKey: ["chat-messages", String(userIdValue)],
});
}
})().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((err) => {
log(1, "chat", "red", "Failed to decrypt live message", err, {
SendTime: data.Message?.SendTime,
});
return null;
})
.then((content) => {
if (!data.Message) return;
addLiveMessage({
...data.Message,
Content: content ?? "Failed to decrypt message",
decryptionFailed: content === null,
SentBySelf: false,
});
});
return;
}
if (message.type !== "MessageState") {
return;
}
if (message.type !== "MessageState") return;
const rawData = message.data as {
ChatPartnerId: unknown;
@ -575,21 +473,14 @@ export default function Provider({ children }: { children: ReactNode }) {
},
);
});
}, [
addLiveMessage,
currentChatSecret,
getUser,
load,
send,
subscribePush,
userIdValue,
]);
}, [addLiveMessage, currentChatSecret, send, subscribePush, userIdValue]);
return (
<QueryClientProvider client={queryClient}>
<context.Provider
value={{
getMessages,
getChatSecret,
liveMessages: () => liveMessagesState,
addLiveMessage,
clearLiveMessages,
@ -608,6 +499,7 @@ export default function Provider({ children }: { children: ReactNode }) {
type contextType = {
getMessages: (amount: number, offset: number) => Promise<RawMessages>;
getChatSecret: (userId: number) => Promise<Uint8Array | null>;
liveMessages: () => LiveMessage[];
addLiveMessage: (message: RawMessage) => {
setFailed: (failed: boolean) => void;

View file

@ -2,3 +2,4 @@
- Add default-emoji-hotkey
- Placeholder image if media fails to load
- Signature verifications via ed25519 key
- Confirmation when exiting with text in the input box.

View file

@ -49,12 +49,6 @@ describe("createCryptoActions", () => {
secret: string,
ciphertext: string,
): Promise<string> => `${secret}|${ciphertext}`,
getSharedSecret: async (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> =>
`${ownPrivateKey}.${ownPublicKey}.${otherPublicKey}`,
};
const actions = createCryptoActions(() => api);
@ -67,6 +61,5 @@ describe("createCryptoActions", () => {
).toBe("s|c");
expect(await actions.encryptText("s", "p")).toBe("s:p");
expect(await actions.decryptText("s", "c")).toBe("s|c");
expect(await actions.getSharedSecret("a", "b", "c")).toBe("a.b.c");
});
});

View file

@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
import { base64ToBytes, bytesToBase64, crypto } from "mtp";
import { base64ToBytes, crypto } from "mtp";
type CryptoContextType = {
decrypt: (
@ -12,11 +12,6 @@ type CryptoContextType = {
input: Uint8Array<ArrayBuffer>,
) => Promise<Uint8Array<ArrayBuffer>>;
encryptText: (secret: string, plaintext: string) => Promise<string>;
getSharedSecret: (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
export const context = createContext<CryptoContextType | undefined>(undefined);
@ -39,8 +34,6 @@ export function createCryptoActions(
encrypt: (secret, input) => requireApi().encrypt(secret, input),
encryptText: (secret, plaintext) =>
requireApi().encryptText(secret, plaintext),
getSharedSecret: (ownPrivateKey, ownPublicKey, otherPublicKey) =>
requireApi().getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey),
};
}
@ -58,45 +51,6 @@ function secretKeyFromString(secret: string): Uint8Array {
);
}
function compareBytes(a: Uint8Array, b: Uint8Array): number {
const len = Math.min(a.byteLength, b.byteLength);
for (let i = 0; i < len; i++) {
const diff = a[i] - b[i];
if (diff !== 0) return diff;
}
return a.byteLength - b.byteLength;
}
async function getSharedSecret(
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> {
crypto.keyringToKeys(ownPrivateKey);
const ownKeys = crypto.publicKeyBundleToKeys(ownPublicKey);
const otherKeys = crypto.publicKeyBundleToKeys(otherPublicKey);
const publicKeys = [ownKeys.kemPublicKey, otherKeys.kemPublicKey].sort(
compareBytes,
);
const input = new Uint8Array(
publicKeys[0].byteLength + publicKeys[1].byteLength,
);
input.set(publicKeys[0]);
input.set(publicKeys[1], publicKeys[0].byteLength);
return bytesToBase64(
crypto.deriveEncryptionKey(
input,
new Uint8Array(0),
new TextEncoder().encode("tensamin:legacy-shared-secret"),
),
);
}
export default function Provider(props: { children: React.ReactNode }) {
const actions = createCryptoActions(() => ({
decrypt: async (secret, input) =>
@ -107,7 +61,6 @@ export default function Provider(props: { children: React.ReactNode }) {
ownedBytes(await crypto.encrypt(secretKeyFromString(secret), input)),
encryptText: (secret, plaintext) =>
crypto.encryptText(secretKeyFromString(secret), plaintext),
getSharedSecret,
}));
return <context.Provider value={actions}>{props.children}</context.Provider>;

View file

@ -201,7 +201,6 @@ export function Provider(props: {
const unsubscribers = [
"MessageLive",
"MessageState",
"ChatSecretForward",
"CallInvite",
"ErrorNoIota",
].map((type) =>

View file

@ -1,16 +1,16 @@
import { useCrypto } from "@tensamin/crypto/context";
import { useStorage } from "@tensamin/storage/context";
import { useUser } from "@tensamin/user/context";
import { useChat } from "@tensamin/chat/context";
import { useMTP } from "@tensamin/mtp";
import { createContext, useEffect, useContext } from "react";
import z from "zod";
import { toast as sonnerToast } from "sonner";
import { Message as MessageSchema } from "@tensamin/shared/data";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session";
import { useNavigate } from "@tanstack/react-router";
import { decryptChatText } from "@tensamin/crypto/chatSecret";
import { log } from "@tensamin/shared/log";
import { type RawMessage } from "@tensamin/chat/values";
export const context = createContext<contextType | undefined>(undefined);
@ -27,108 +27,113 @@ export default function Provider(props: { children: React.ReactNode }) {
const { subscribePush, send } = useMTP();
const { load } = useStorage();
const { get } = useUser();
const { decryptText, getSharedSecret } = useCrypto();
const { addLiveMessage, userId } = useChat();
const { addLiveMessage, getChatSecret, userId } = useChat();
const { moveUserIdToTop } = useSession();
const navigate = useNavigate();
useEffect(() => {
return subscribePush(async (ttpMessage) => {
if (ttpMessage.type === "MessageLive") {
const { message, SenderId } = ttpMessage.data as {
message: z.infer<typeof MessageSchema>;
SenderId: number;
return subscribePush(async (message) => {
if (message.type === "MessageLive") {
const data = message.data as {
Message?: RawMessage;
SenderId?: number;
};
const user = await get(SenderId);
if (!data.SenderId) return;
const decryptedContent = await decryptText(
await getSharedSecret(
await load("mtp_keyring"),
await get(await load("user_id")).then((data) => data.PublicKey),
user.PublicKey,
),
message.Content,
);
const chatSecret = await getChatSecret(data.SenderId);
// Update message state
if (userId === SenderId) {
addLiveMessage({
...message,
Content: decryptedContent,
SentBySelf: false,
});
return;
}
if (!data.Message || !chatSecret) return;
const user = await get(data.SenderId);
// todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(SenderId);
if (await load("settings.receive_confirmations")) {
void send(
"MessageState",
{
MessageState: "received",
},
{
id: ttpMessage.id,
},
);
}
if (isTauri()) {
console.log("weewoo");
} else {
const hasPermissions = await requestNotificationPermission();
if (hasPermissions) {
const notification = new Notification(user.Display, {
body: decryptedContent,
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
tag: `message-${user.UserId}`,
silent: true,
void decryptChatText(chatSecret, data.Message.Content)
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt live message", err, {
SendTime: data.Message?.SendTime,
});
return null;
})
.then(async (content) => {
if (!data.Message || !content || !data.SenderId) return;
notification.onclick = () => {
window.focus();
navigate({
to: `/chat?id=${user.UserId}`,
if (userId === data.SenderId) {
addLiveMessage({
...data.Message,
Content: content ?? "Failed to decrypt message",
decryptionFailed: content === null,
SentBySelf: false,
});
return;
}
notification.close();
};
} else {
sonnerToast(user.Display, {
classNames: {
content: "pl-4",
},
description: decryptedContent,
icon: (
<Avatar>
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
),
});
}
}
// todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(data.SenderId);
if (await load("settings.receive_confirmations")) {
void send(
"MessageState",
{
MessageState: "received",
},
{
id: data.Message.SendTime,
},
);
}
if (isTauri()) {
console.log("weewoo");
} else {
const hasPermissions = await requestNotificationPermission();
if (hasPermissions) {
const notification = new Notification(user.Display, {
body: content,
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
tag: `message-${user.UserId}`,
silent: true,
});
notification.onclick = () => {
window.focus();
navigate({
to: `/chat?id=${user.UserId}`,
});
notification.close();
};
} else {
sonnerToast(user.Display, {
classNames: {
content: "pl-4",
},
description: content,
icon: (
<Avatar>
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
),
});
}
}
});
return;
}
});
}, [
subscribePush,
decryptText,
getSharedSecret,
load,
get,
addLiveMessage,
userId,
moveUserIdToTop,
send,
get,
load,
navigate,
send,
moveUserIdToTop,
subscribePush,
getChatSecret,
userId,
]);
return (

View file

@ -32,6 +32,12 @@ const chatSecretResponse = z.object({
UpdatedAt: z.number(),
});
const chatSecretRecipient = z.object({
UserId: z.string(),
EncryptedSecret: protocolBytes,
KemCiphertext: protocolBytes,
});
export const Message = z.object({
NotEncrypted: z.boolean().optional(),
SentBySelf: z.boolean().optional(),
@ -267,14 +273,12 @@ export const mtp = {
},
SetChatSecret: {
request: z.object({
UserId: z.string(),
ChatId: z.string(),
SecretId: z.string(),
VersionNumber: z.number(),
EncryptedSecret: protocolBytes,
KemCiphertext: protocolBytes,
WrappingScheme: z.string(),
CreatedAt: z.number(),
Recipients: z.array(chatSecretRecipient).min(1),
}),
response: z.object({}),
},
@ -308,6 +312,10 @@ export const mtp = {
request: z.object({}).optional(),
response: z.object({}),
},
ErrorNotSet: {
request: z.object({}).optional(),
response: z.object({}),
},
} satisfies Record<string, { request: z.ZodType; response: z.ZodType }>;
export type MTP = typeof mtp;

View file

@ -137,6 +137,7 @@ type_maps:
AppChallengeResponse: 133
AppIdentificationResponse: 134
LoadTxtRecord: 135
ErrorNotSet: 136
SetChatSecret: 139
GetChatSecret: 140
ChatSecretResponse: 141
@ -254,3 +255,4 @@ type_maps:
KemCiphertext: 149
SenderUserId: 152
RecipientUserId: 153
Recipients: 154