(feat): finish chat crypto migration
(wip): prep for full ECDH repo migration
This commit is contained in:
parent
ecca5241bc
commit
2cd326d0b1
10 changed files with 195 additions and 339 deletions
|
|
@ -7,7 +7,8 @@
|
||||||
"./context": "./src/context.tsx",
|
"./context": "./src/context.tsx",
|
||||||
"./screen": "./src/screen.tsx",
|
"./screen": "./src/screen.tsx",
|
||||||
"./behaviour-conversation": "./src/behaviour/conversation.ts",
|
"./behaviour-conversation": "./src/behaviour/conversation.ts",
|
||||||
"./behaviour-community": "./src/behaviour/community.ts"
|
"./behaviour-community": "./src/behaviour/community.ts",
|
||||||
|
"./values": "./src/values.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ function MessageComponent({
|
||||||
}
|
}
|
||||||
}, [load, message.SendTime, user?.UserId, send]);
|
}, [load, message.SendTime, user?.UserId, send]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (message.SentBySelf) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
|
@ -111,7 +113,7 @@ function MessageComponent({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [message.MessageState, load, messageStateReadUpdate]);
|
}, [message.MessageState, load, messageStateReadUpdate, message.SentBySelf]);
|
||||||
|
|
||||||
// Check if message is a url
|
// Check if message is a url
|
||||||
const [isValidURL, setIsValidURL] = useState(false);
|
const [isValidURL, setIsValidURL] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,16 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
return currentChatSecretState.value;
|
return currentChatSecretState.value;
|
||||||
}, [currentChatSecretState, userIdValue]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!userIdValue) return;
|
if (!userIdValue) return;
|
||||||
|
|
||||||
|
|
@ -149,34 +159,6 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
const chatId = deriveChatId(ownUserId, userIdValue);
|
const chatId = deriveChatId(ownUserId, userIdValue);
|
||||||
const secretId = deriveChatSecretId(chatId);
|
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", {
|
const existing = await send("GetChatSecret", {
|
||||||
UserId: String(ownUserId),
|
UserId: String(ownUserId),
|
||||||
ChatId: chatId,
|
ChatId: chatId,
|
||||||
|
|
@ -198,11 +180,13 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
if (active) {
|
if (active) {
|
||||||
setCurrentChatSecretState({ userId: userIdValue, value: secret });
|
setCurrentChatSecretState({ userId: userIdValue, value: secret });
|
||||||
}
|
}
|
||||||
|
|
||||||
await forwardChatSecret(secret);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.type !== "ErrorNotSet") {
|
||||||
|
throw new Error(`GetChatSecret failed: ${existing.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
const rawSecret = randomChatSecret();
|
const rawSecret = randomChatSecret();
|
||||||
const ownWrapped = await wrapChatSecret({
|
const ownWrapped = await wrapChatSecret({
|
||||||
chatSecret: rawSecret,
|
chatSecret: rawSecret,
|
||||||
|
|
@ -211,23 +195,40 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
secretId,
|
secretId,
|
||||||
version: CHAT_SECRET_VERSION,
|
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(
|
assertProtocolSuccess(
|
||||||
"SetChatSecret",
|
"SetChatSecret",
|
||||||
await send("SetChatSecret", {
|
await send("SetChatSecret", {
|
||||||
UserId: String(ownUserId),
|
|
||||||
ChatId: chatId,
|
ChatId: chatId,
|
||||||
SecretId: secretId,
|
SecretId: secretId,
|
||||||
VersionNumber: CHAT_SECRET_VERSION,
|
VersionNumber: CHAT_SECRET_VERSION,
|
||||||
EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret),
|
|
||||||
KemCiphertext: protocolBytes(ownWrapped.kemCiphertext),
|
|
||||||
WrappingScheme: ownWrapped.wrappingScheme,
|
WrappingScheme: ownWrapped.wrappingScheme,
|
||||||
CreatedAt: Date.now(),
|
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) {
|
if (active) {
|
||||||
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
|
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +247,45 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
}, [getUser, load, send, userIdValue]);
|
}, [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(
|
const getMessages = useCallback(
|
||||||
async (amount: number, offset: number) => {
|
async (amount: number, offset: number) => {
|
||||||
if (!currentChatSecret) {
|
if (!currentChatSecret) {
|
||||||
|
|
@ -344,149 +384,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
// Get live updates for message states
|
// Get live updates for message states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribePush((message) => {
|
return subscribePush((message) => {
|
||||||
if (message.type === "ChatSecretForward") {
|
if (message.type !== "MessageState") return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawData = message.data as {
|
const rawData = message.data as {
|
||||||
ChatPartnerId: unknown;
|
ChatPartnerId: unknown;
|
||||||
|
|
@ -575,21 +473,14 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [
|
}, [addLiveMessage, currentChatSecret, send, subscribePush, userIdValue]);
|
||||||
addLiveMessage,
|
|
||||||
currentChatSecret,
|
|
||||||
getUser,
|
|
||||||
load,
|
|
||||||
send,
|
|
||||||
subscribePush,
|
|
||||||
userIdValue,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<context.Provider
|
<context.Provider
|
||||||
value={{
|
value={{
|
||||||
getMessages,
|
getMessages,
|
||||||
|
getChatSecret,
|
||||||
liveMessages: () => liveMessagesState,
|
liveMessages: () => liveMessagesState,
|
||||||
addLiveMessage,
|
addLiveMessage,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
|
|
@ -608,6 +499,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
type contextType = {
|
type contextType = {
|
||||||
getMessages: (amount: number, offset: number) => Promise<RawMessages>;
|
getMessages: (amount: number, offset: number) => Promise<RawMessages>;
|
||||||
|
getChatSecret: (userId: number) => Promise<Uint8Array | null>;
|
||||||
liveMessages: () => LiveMessage[];
|
liveMessages: () => LiveMessage[];
|
||||||
addLiveMessage: (message: RawMessage) => {
|
addLiveMessage: (message: RawMessage) => {
|
||||||
setFailed: (failed: boolean) => void;
|
setFailed: (failed: boolean) => void;
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
- Add default-emoji-hotkey
|
- Add default-emoji-hotkey
|
||||||
- Placeholder image if media fails to load
|
- Placeholder image if media fails to load
|
||||||
- Signature verifications via ed25519 key
|
- Signature verifications via ed25519 key
|
||||||
|
- Confirmation when exiting with text in the input box.
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,6 @@ describe("createCryptoActions", () => {
|
||||||
secret: string,
|
secret: string,
|
||||||
ciphertext: string,
|
ciphertext: string,
|
||||||
): Promise<string> => `${secret}|${ciphertext}`,
|
): Promise<string> => `${secret}|${ciphertext}`,
|
||||||
getSharedSecret: async (
|
|
||||||
ownPrivateKey: string,
|
|
||||||
ownPublicKey: string,
|
|
||||||
otherPublicKey: string,
|
|
||||||
): Promise<string> =>
|
|
||||||
`${ownPrivateKey}.${ownPublicKey}.${otherPublicKey}`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions = createCryptoActions(() => api);
|
const actions = createCryptoActions(() => api);
|
||||||
|
|
@ -67,6 +61,5 @@ describe("createCryptoActions", () => {
|
||||||
).toBe("s|c");
|
).toBe("s|c");
|
||||||
expect(await actions.encryptText("s", "p")).toBe("s:p");
|
expect(await actions.encryptText("s", "p")).toBe("s:p");
|
||||||
expect(await actions.decryptText("s", "c")).toBe("s|c");
|
expect(await actions.decryptText("s", "c")).toBe("s|c");
|
||||||
expect(await actions.getSharedSecret("a", "b", "c")).toBe("a.b.c");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { base64ToBytes, bytesToBase64, crypto } from "mtp";
|
import { base64ToBytes, crypto } from "mtp";
|
||||||
|
|
||||||
type CryptoContextType = {
|
type CryptoContextType = {
|
||||||
decrypt: (
|
decrypt: (
|
||||||
|
|
@ -12,11 +12,6 @@ type CryptoContextType = {
|
||||||
input: Uint8Array<ArrayBuffer>,
|
input: Uint8Array<ArrayBuffer>,
|
||||||
) => Promise<Uint8Array<ArrayBuffer>>;
|
) => Promise<Uint8Array<ArrayBuffer>>;
|
||||||
encryptText: (secret: string, plaintext: string) => Promise<string>;
|
encryptText: (secret: string, plaintext: string) => Promise<string>;
|
||||||
getSharedSecret: (
|
|
||||||
ownPrivateKey: string,
|
|
||||||
ownPublicKey: string,
|
|
||||||
otherPublicKey: string,
|
|
||||||
) => Promise<string>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const context = createContext<CryptoContextType | undefined>(undefined);
|
export const context = createContext<CryptoContextType | undefined>(undefined);
|
||||||
|
|
@ -39,8 +34,6 @@ export function createCryptoActions(
|
||||||
encrypt: (secret, input) => requireApi().encrypt(secret, input),
|
encrypt: (secret, input) => requireApi().encrypt(secret, input),
|
||||||
encryptText: (secret, plaintext) =>
|
encryptText: (secret, plaintext) =>
|
||||||
requireApi().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 }) {
|
export default function Provider(props: { children: React.ReactNode }) {
|
||||||
const actions = createCryptoActions(() => ({
|
const actions = createCryptoActions(() => ({
|
||||||
decrypt: async (secret, input) =>
|
decrypt: async (secret, input) =>
|
||||||
|
|
@ -107,7 +61,6 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
ownedBytes(await crypto.encrypt(secretKeyFromString(secret), input)),
|
ownedBytes(await crypto.encrypt(secretKeyFromString(secret), input)),
|
||||||
encryptText: (secret, plaintext) =>
|
encryptText: (secret, plaintext) =>
|
||||||
crypto.encryptText(secretKeyFromString(secret), plaintext),
|
crypto.encryptText(secretKeyFromString(secret), plaintext),
|
||||||
getSharedSecret,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <context.Provider value={actions}>{props.children}</context.Provider>;
|
return <context.Provider value={actions}>{props.children}</context.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,6 @@ export function Provider(props: {
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
"MessageLive",
|
"MessageLive",
|
||||||
"MessageState",
|
"MessageState",
|
||||||
"ChatSecretForward",
|
|
||||||
"CallInvite",
|
"CallInvite",
|
||||||
"ErrorNoIota",
|
"ErrorNoIota",
|
||||||
].map((type) =>
|
].map((type) =>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { useCrypto } from "@tensamin/crypto/context";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useUser } from "@tensamin/user/context";
|
import { useUser } from "@tensamin/user/context";
|
||||||
import { useChat } from "@tensamin/chat/context";
|
import { useChat } from "@tensamin/chat/context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { createContext, useEffect, useContext } from "react";
|
import { createContext, useEffect, useContext } from "react";
|
||||||
import z from "zod";
|
|
||||||
import { toast as sonnerToast } from "sonner";
|
import { toast as sonnerToast } from "sonner";
|
||||||
import { Message as MessageSchema } from "@tensamin/shared/data";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
|
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
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);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
||||||
|
|
@ -27,108 +27,113 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
const { subscribePush, send } = useMTP();
|
const { subscribePush, send } = useMTP();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
const { decryptText, getSharedSecret } = useCrypto();
|
const { addLiveMessage, getChatSecret, userId } = useChat();
|
||||||
const { addLiveMessage, userId } = useChat();
|
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribePush(async (ttpMessage) => {
|
return subscribePush(async (message) => {
|
||||||
if (ttpMessage.type === "MessageLive") {
|
if (message.type === "MessageLive") {
|
||||||
const { message, SenderId } = ttpMessage.data as {
|
const data = message.data as {
|
||||||
message: z.infer<typeof MessageSchema>;
|
Message?: RawMessage;
|
||||||
SenderId: number;
|
SenderId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const user = await get(SenderId);
|
if (!data.SenderId) return;
|
||||||
|
|
||||||
const decryptedContent = await decryptText(
|
const chatSecret = await getChatSecret(data.SenderId);
|
||||||
await getSharedSecret(
|
|
||||||
await load("mtp_keyring"),
|
|
||||||
await get(await load("user_id")).then((data) => data.PublicKey),
|
|
||||||
user.PublicKey,
|
|
||||||
),
|
|
||||||
message.Content,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update message state
|
if (!data.Message || !chatSecret) return;
|
||||||
if (userId === SenderId) {
|
const user = await get(data.SenderId);
|
||||||
addLiveMessage({
|
|
||||||
...message,
|
|
||||||
Content: decryptedContent,
|
|
||||||
SentBySelf: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo: add notification symbol to conversation cards (incl. message start)
|
void decryptChatText(chatSecret, data.Message.Content)
|
||||||
|
.catch((err) => {
|
||||||
moveUserIdToTop(SenderId);
|
log(1, "chat", "red", "Failed to decrypt live message", err, {
|
||||||
|
SendTime: data.Message?.SendTime,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.then(async (content) => {
|
||||||
|
if (!data.Message || !content || !data.SenderId) return;
|
||||||
|
|
||||||
notification.onclick = () => {
|
if (userId === data.SenderId) {
|
||||||
window.focus();
|
addLiveMessage({
|
||||||
navigate({
|
...data.Message,
|
||||||
to: `/chat?id=${user.UserId}`,
|
Content: content ?? "Failed to decrypt message",
|
||||||
|
decryptionFailed: content === null,
|
||||||
|
SentBySelf: false,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
notification.close();
|
// todo: add notification symbol to conversation cards (incl. message start)
|
||||||
};
|
moveUserIdToTop(data.SenderId);
|
||||||
} else {
|
|
||||||
sonnerToast(user.Display, {
|
if (await load("settings.receive_confirmations")) {
|
||||||
classNames: {
|
void send(
|
||||||
content: "pl-4",
|
"MessageState",
|
||||||
},
|
{
|
||||||
description: decryptedContent,
|
MessageState: "received",
|
||||||
icon: (
|
},
|
||||||
<Avatar>
|
{
|
||||||
<AvatarImage src={user.Avatar} />
|
id: data.Message.SendTime,
|
||||||
<AvatarFallback>
|
},
|
||||||
{user.Display.slice(0, 2).toUpperCase()}
|
);
|
||||||
</AvatarFallback>
|
}
|
||||||
</Avatar>
|
|
||||||
),
|
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,
|
addLiveMessage,
|
||||||
userId,
|
get,
|
||||||
moveUserIdToTop,
|
load,
|
||||||
send,
|
|
||||||
navigate,
|
navigate,
|
||||||
|
send,
|
||||||
|
moveUserIdToTop,
|
||||||
|
subscribePush,
|
||||||
|
getChatSecret,
|
||||||
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ const chatSecretResponse = z.object({
|
||||||
UpdatedAt: z.number(),
|
UpdatedAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const chatSecretRecipient = z.object({
|
||||||
|
UserId: z.string(),
|
||||||
|
EncryptedSecret: protocolBytes,
|
||||||
|
KemCiphertext: protocolBytes,
|
||||||
|
});
|
||||||
|
|
||||||
export const Message = z.object({
|
export const Message = z.object({
|
||||||
NotEncrypted: z.boolean().optional(),
|
NotEncrypted: z.boolean().optional(),
|
||||||
SentBySelf: z.boolean().optional(),
|
SentBySelf: z.boolean().optional(),
|
||||||
|
|
@ -267,14 +273,12 @@ export const mtp = {
|
||||||
},
|
},
|
||||||
SetChatSecret: {
|
SetChatSecret: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
UserId: z.string(),
|
|
||||||
ChatId: z.string(),
|
ChatId: z.string(),
|
||||||
SecretId: z.string(),
|
SecretId: z.string(),
|
||||||
VersionNumber: z.number(),
|
VersionNumber: z.number(),
|
||||||
EncryptedSecret: protocolBytes,
|
|
||||||
KemCiphertext: protocolBytes,
|
|
||||||
WrappingScheme: z.string(),
|
WrappingScheme: z.string(),
|
||||||
CreatedAt: z.number(),
|
CreatedAt: z.number(),
|
||||||
|
Recipients: z.array(chatSecretRecipient).min(1),
|
||||||
}),
|
}),
|
||||||
response: z.object({}),
|
response: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
@ -308,6 +312,10 @@ export const mtp = {
|
||||||
request: z.object({}).optional(),
|
request: z.object({}).optional(),
|
||||||
response: z.object({}),
|
response: z.object({}),
|
||||||
},
|
},
|
||||||
|
ErrorNotSet: {
|
||||||
|
request: z.object({}).optional(),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
} satisfies Record<string, { request: z.ZodType; response: z.ZodType }>;
|
} satisfies Record<string, { request: z.ZodType; response: z.ZodType }>;
|
||||||
|
|
||||||
export type MTP = typeof mtp;
|
export type MTP = typeof mtp;
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ type_maps:
|
||||||
AppChallengeResponse: 133
|
AppChallengeResponse: 133
|
||||||
AppIdentificationResponse: 134
|
AppIdentificationResponse: 134
|
||||||
LoadTxtRecord: 135
|
LoadTxtRecord: 135
|
||||||
|
ErrorNotSet: 136
|
||||||
SetChatSecret: 139
|
SetChatSecret: 139
|
||||||
GetChatSecret: 140
|
GetChatSecret: 140
|
||||||
ChatSecretResponse: 141
|
ChatSecretResponse: 141
|
||||||
|
|
@ -254,3 +255,4 @@ type_maps:
|
||||||
KemCiphertext: 149
|
KemCiphertext: 149
|
||||||
SenderUserId: 152
|
SenderUserId: 152
|
||||||
RecipientUserId: 153
|
RecipientUserId: 153
|
||||||
|
Recipients: 154
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue