(wip): fix chat crypto
Some checks failed
/ build-web (push) Successful in 7m21s
/ build-desktop (linux) (push) Failing after 7m49s
/ build-mobile (push) Successful in 20m5s
/ release (push) Has been skipped

This commit is contained in:
Alois 2026-07-06 17:52:41 +02:00
commit 8ddc8db536
6 changed files with 195 additions and 82 deletions

View file

@ -35,6 +35,10 @@ const CHAT_SECRET_VERSION = 1;
function bytesFromProtocol(value: unknown): Uint8Array {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
if (Array.isArray(value)) return new Uint8Array(value);
if (typeof value === "string") {
const bin = atob(value);
@ -42,6 +46,16 @@ function bytesFromProtocol(value: unknown): Uint8Array {
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
if (typeof value === "object" && value !== null) {
const entries = Object.entries(value);
if (
entries.every(
([key, item]) => /^\d+$/.test(key) && typeof item === "number",
)
) {
return new Uint8Array(entries.map(([, item]) => item as number));
}
}
throw new Error("expected protocol bytes");
}
@ -72,6 +86,16 @@ function updateMessageStateBySendTime<
};
}
function assertProtocolSuccess(type: string, response: { type: string }) {
if (response.type.startsWith("Error")) {
throw new Error(`${type} failed: ${response.type}`);
}
}
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
return new Uint8Array(bytes);
}
export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage();
const { send, subscribePush } = useMTP();
@ -125,6 +149,34 @@ 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,
@ -146,6 +198,8 @@ export default function Provider({ children }: { children: ReactNode }) {
if (active) {
setCurrentChatSecretState({ userId: userIdValue, value: secret });
}
await forwardChatSecret(secret);
return;
}
@ -158,39 +212,21 @@ export default function Provider({ children }: { children: ReactNode }) {
version: CHAT_SECRET_VERSION,
});
assertProtocolSuccess(
"SetChatSecret",
await send("SetChatSecret", {
UserId: String(ownUserId),
ChatId: chatId,
SecretId: secretId,
VersionNumber: CHAT_SECRET_VERSION,
EncryptedSecret: Array.from(ownWrapped.encryptedSecret),
KemCiphertext: Array.from(ownWrapped.kemCiphertext),
EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret),
KemCiphertext: protocolBytes(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(),
});
await forwardChatSecret(rawSecret);
if (active) {
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
@ -317,6 +353,68 @@ export default function Provider({ children }: { children: ReactNode }) {
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),
@ -334,19 +432,25 @@ export default function Provider({ children }: { children: ReactNode }) {
version: Number(data.VersionNumber),
});
assertProtocolSuccess(
"SetChatSecret",
await send("SetChatSecret", {
UserId: String(ownUserId),
ChatId: chatId,
SecretId: secretId,
VersionNumber: Number(data.VersionNumber),
EncryptedSecret: Array.from(ownWrapped.encryptedSecret),
KemCiphertext: Array.from(ownWrapped.kemCiphertext),
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);
@ -474,6 +578,7 @@ export default function Provider({ children }: { children: ReactNode }) {
}, [
addLiveMessage,
currentChatSecret,
getUser,
load,
send,
subscribePush,

View file

@ -1,6 +1,7 @@
import { crypto } from "mtp";
import { base64ToBytes, bytesToBase64, crypto } from "mtp";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export const CHAT_SECRET_WRAPPING_SCHEME =
"mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1";
@ -73,11 +74,16 @@ export async function unwrapChatSecret(args: {
wrappingScheme: string;
}): Promise<Uint8Array> {
if (args.wrappingScheme !== CHAT_SECRET_WRAPPING_SCHEME) {
throw new Error(`Unsupported chat secret wrapping scheme: ${args.wrappingScheme}`);
throw new Error(
`Unsupported chat secret wrapping scheme: ${args.wrappingScheme}`,
);
}
const ownKeys = crypto.keyringToKeys(args.keyring);
const sharedSecret = crypto.decapsulate(ownKeys.kemSecretKey, args.kemCiphertext);
const sharedSecret = crypto.decapsulate(
ownKeys.kemSecretKey,
args.kemCiphertext,
);
try {
const wrappingKey = deriveWrappingKey({
@ -103,7 +109,9 @@ export async function encryptChatText(
): Promise<string> {
const key = deriveMessageKey(chatSecret);
try {
return await crypto.encryptText(key, plaintext);
return bytesToBase64(
await crypto.encrypt(key, textEncoder.encode(plaintext)),
);
} finally {
key.fill(0);
}
@ -115,7 +123,9 @@ export async function decryptChatText(
): Promise<string> {
const key = deriveMessageKey(chatSecret);
try {
return await crypto.decryptText(key, ciphertext);
return textDecoder.decode(
await crypto.decrypt(key, base64ToBytes(ciphertext)),
);
} finally {
key.fill(0);
}

View file

@ -111,7 +111,9 @@ function validateResponse<T extends keyof Schemas & string>(
return message as ProtocolMessage<T>;
}
const schema = schemas[type]?.response;
const schema =
schemas[message.type as keyof Schemas & string]?.response ??
schemas[type]?.response;
if (!schema) {
return message as ProtocolMessage<T>;
}
@ -574,15 +576,7 @@ export function Provider(props: {
subscribePush,
});
}
}, [
connected,
identified,
mtpUrl,
send,
subscribe,
subscribePush,
mtpRef,
]);
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
const sendQueued: BoundSendFn = useMemo(
() => async (type, data, options) => {

View file

@ -18,6 +18,20 @@ const bytesLike = z.union([
z.base64(),
]);
const protocolBytes = z.instanceof(Uint8Array);
const chatSecretResponse = z.object({
UserId: z.string(),
ChatId: z.string(),
SecretId: z.string(),
VersionNumber: z.number(),
EncryptedSecret: bytesLike,
KemCiphertext: bytesLike,
WrappingScheme: z.string(),
CreatedAt: z.number(),
UpdatedAt: z.number(),
});
export const Message = z.object({
NotEncrypted: z.boolean().optional(),
SentBySelf: z.boolean().optional(),
@ -257,8 +271,8 @@ export const mtp = {
ChatId: z.string(),
SecretId: z.string(),
VersionNumber: z.number(),
EncryptedSecret: bytesLike,
KemCiphertext: bytesLike,
EncryptedSecret: protocolBytes,
KemCiphertext: protocolBytes,
WrappingScheme: z.string(),
CreatedAt: z.number(),
}),
@ -270,21 +284,11 @@ export const mtp = {
ChatId: z.string(),
SecretId: z.string().optional(),
}),
response: z.object({}),
response: chatSecretResponse,
},
ChatSecretResponse: {
request: z.object({}).optional(),
response: z.object({
UserId: z.string(),
ChatId: z.string(),
SecretId: z.string(),
VersionNumber: z.number(),
EncryptedSecret: bytesLike,
KemCiphertext: bytesLike,
WrappingScheme: z.string(),
CreatedAt: z.number(),
UpdatedAt: z.number(),
}),
response: chatSecretResponse,
},
ChatSecretForward: {
request: z.object({
@ -293,8 +297,8 @@ export const mtp = {
RecipientUserId: z.string(),
SecretId: z.string(),
VersionNumber: z.number(),
EncryptedSecret: bytesLike,
KemCiphertext: bytesLike,
EncryptedSecret: protocolBytes,
KemCiphertext: protocolBytes,
WrappingScheme: z.string(),
CreatedAt: z.number(),
}),

16
pnpm-lock.yaml generated
View file

@ -6,7 +6,7 @@ settings:
overrides:
'@tensamin/ui': https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz
importers:
@ -16,8 +16,8 @@ importers:
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@ -652,8 +652,8 @@ importers:
specifier: ^1.14.0
version: 1.23.0(react@19.2.7)
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz
react:
specifier: ^19.2.0
version: 19.2.7
@ -3736,8 +3736,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz:
resolution: {integrity: sha512-Y18aXdMfM80YLBGzk3NS5PVl08GHX2Cfkw0aQK06xazA3zZlPVHStS7enZ4s4MmDzE6NNTYBYbw49nWpUgkdvQ==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz:
resolution: {integrity: sha512-A2SN971Er728ibaemcL1qu1PGJuOZxNGY8ssgld3ETF94tVJ3GeBHQERcbQNdj9fExPEmnjkq/o1tt8W+Vdcvw==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz}
version: 0.1.0
nanoid@3.3.15:
@ -7857,7 +7857,7 @@ snapshots:
ms@2.1.3: {}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz: {}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz: {}
nanoid@3.3.15: {}

View file

@ -7,4 +7,4 @@ allowBuilds:
esbuild: true
overrides:
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz"