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
6 changed files with 195 additions and 82 deletions
Showing only changes of commit 8ddc8db536 - Show all commits

(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

Alois 2026-07-06 17:52:41 +02:00

View file

@ -35,6 +35,10 @@ const CHAT_SECRET_VERSION = 1;
function bytesFromProtocol(value: unknown): Uint8Array { function bytesFromProtocol(value: unknown): Uint8Array {
if (value instanceof Uint8Array) return value; 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 (Array.isArray(value)) return new Uint8Array(value);
if (typeof value === "string") { if (typeof value === "string") {
const bin = atob(value); 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); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out; 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"); 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 }) { export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage(); const { load } = useStorage();
const { send, subscribePush } = useMTP(); const { send, subscribePush } = useMTP();
@ -125,6 +149,34 @@ 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,
@ -146,6 +198,8 @@ 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;
} }
@ -158,39 +212,21 @@ export default function Provider({ children }: { children: ReactNode }) {
version: CHAT_SECRET_VERSION, version: CHAT_SECRET_VERSION,
}); });
await send("SetChatSecret", { assertProtocolSuccess(
UserId: String(ownUserId), "SetChatSecret",
ChatId: chatId, await send("SetChatSecret", {
SecretId: secretId, UserId: String(ownUserId),
VersionNumber: CHAT_SECRET_VERSION, ChatId: chatId,
EncryptedSecret: Array.from(ownWrapped.encryptedSecret), SecretId: secretId,
KemCiphertext: Array.from(ownWrapped.kemCiphertext), VersionNumber: CHAT_SECRET_VERSION,
WrappingScheme: ownWrapped.wrappingScheme, EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret),
CreatedAt: Date.now(), KemCiphertext: protocolBytes(ownWrapped.kemCiphertext),
}); WrappingScheme: ownWrapped.wrappingScheme,
CreatedAt: Date.now(),
}),
);
const peerUser = await getUser(userIdValue); await forwardChatSecret(rawSecret);
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) { if (active) {
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret }); setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
@ -317,6 +353,68 @@ export default function Provider({ children }: { children: ReactNode }) {
const keyring = await load("mtp_keyring"); const keyring = await load("mtp_keyring");
const chatId = String(data.ChatId); const chatId = String(data.ChatId);
const secretId = String(data.SecretId); 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({ const secret = await unwrapChatSecret({
encryptedSecret: bytesFromProtocol(data.EncryptedSecret), encryptedSecret: bytesFromProtocol(data.EncryptedSecret),
kemCiphertext: bytesFromProtocol(data.KemCiphertext), kemCiphertext: bytesFromProtocol(data.KemCiphertext),
@ -334,19 +432,25 @@ export default function Provider({ children }: { children: ReactNode }) {
version: Number(data.VersionNumber), version: Number(data.VersionNumber),
}); });
await send("SetChatSecret", { assertProtocolSuccess(
UserId: String(ownUserId), "SetChatSecret",
ChatId: chatId, await send("SetChatSecret", {
SecretId: secretId, UserId: String(ownUserId),
VersionNumber: Number(data.VersionNumber), ChatId: chatId,
EncryptedSecret: Array.from(ownWrapped.encryptedSecret), SecretId: secretId,
KemCiphertext: Array.from(ownWrapped.kemCiphertext), VersionNumber: Number(data.VersionNumber),
WrappingScheme: ownWrapped.wrappingScheme, EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret),
CreatedAt: Date.now(), KemCiphertext: protocolBytes(ownWrapped.kemCiphertext),
}); WrappingScheme: ownWrapped.wrappingScheme,
CreatedAt: Date.now(),
}),
);
if (deriveChatId(ownUserId, userIdValue) === chatId) { if (deriveChatId(ownUserId, userIdValue) === chatId) {
setCurrentChatSecretState({ userId: userIdValue, value: secret }); setCurrentChatSecretState({ userId: userIdValue, value: secret });
void queryClient.invalidateQueries({
queryKey: ["chat-messages", String(userIdValue)],
});
} }
})().catch((err) => { })().catch((err) => {
log(1, "chat", "red", "Failed to accept chat secret", err); log(1, "chat", "red", "Failed to accept chat secret", err);
@ -474,6 +578,7 @@ export default function Provider({ children }: { children: ReactNode }) {
}, [ }, [
addLiveMessage, addLiveMessage,
currentChatSecret, currentChatSecret,
getUser,
load, load,
send, send,
subscribePush, subscribePush,

View file

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

View file

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

View file

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

16
pnpm-lock.yaml generated
View file

@ -6,7 +6,7 @@ settings:
overrides: overrides:
'@tensamin/ui': https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz '@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: importers:
@ -16,8 +16,8 @@ importers:
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz 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) 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: mtp:
specifier: 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-515244c/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: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.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 specifier: ^1.14.0
version: 1.23.0(react@19.2.7) version: 1.23.0(react@19.2.7)
mtp: mtp:
specifier: 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-515244c/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: react:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.7 version: 19.2.7
@ -3736,8 +3736,8 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 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: mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/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} 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 version: 0.1.0
nanoid@3.3.15: nanoid@3.3.15:
@ -7857,7 +7857,7 @@ snapshots:
ms@2.1.3: {} 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: {} nanoid@3.3.15: {}

View file

@ -7,4 +7,4 @@ allowBuilds:
esbuild: true esbuild: true
overrides: overrides:
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz" "@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"