(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

This commit is contained in:
Alois 2026-07-06 00:13:26 +02:00
commit 2777ba34ca
11 changed files with 504 additions and 715 deletions

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 { crypto } from "mtp";
import { base64ToBytes, bytesToBase64, crypto } from "mtp";
type CryptoContextType = {
decrypt: (
@ -50,15 +50,64 @@ function ownedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
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 }) {
const actions = createCryptoActions(() => ({
decrypt: async (secret, input) =>
ownedBytes(await crypto.decrypt(secret, input)),
decryptText: crypto.decryptText,
ownedBytes(await crypto.decrypt(secretKeyFromString(secret), input)),
decryptText: (secret, ciphertext) =>
crypto.decryptText(secretKeyFromString(secret), ciphertext),
encrypt: async (secret, input) =>
ownedBytes(await crypto.encrypt(secret, input)),
encryptText: crypto.encryptText,
getSharedSecret: crypto.getSharedSecret,
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

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