(feat): more crypto migration
This commit is contained in:
parent
f3ecb8f3dd
commit
2777ba34ca
11 changed files with 504 additions and 715 deletions
143
packages/crypto/src/chatSecret.ts
Normal file
143
packages/crypto/src/chatSecret.ts
Normal 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"),
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue