Cybersecurity-Projects/PROJECTS/advanced/encrypted-p2p-chat/frontend/src/crypto/double-ratchet.ts

386 lines
11 KiB
TypeScript

// ===================
// © AngelaMos | 2026
// double-ratchet.ts
// ===================
import type {
DoubleRatchetState,
EncryptedMessage,
MessageHeader,
SerializedRatchetState,
} from '../types'
import { MAX_SKIP_MESSAGE_KEYS } from '../types'
import {
aesGcmDecrypt,
aesGcmEncrypt,
base64ToBytes,
bytesToBase64,
concatBytes,
exportPrivateKey,
exportPublicKey,
generateX25519KeyPair,
hkdfDerive,
hmacSha256,
importX25519PrivateKey,
importX25519PublicKey,
x25519DeriveSharedSecret,
} from './primitives'
const RATCHET_INFO = new TextEncoder().encode('DoubleRatchet')
const MESSAGE_KEY_BYTE = new Uint8Array([0x01])
const CHAIN_KEY_BYTE = new Uint8Array([0x02])
function createSkippedKeyId(
dhPublicKey: Uint8Array,
messageNumber: number
): string {
return `${bytesToBase64(dhPublicKey)}:${messageNumber}`
}
export async function initializeRatchetSender(
peerId: string,
sharedKey: Uint8Array,
peerPublicKey: Uint8Array
): Promise<DoubleRatchetState> {
const dhKeyPair = await generateX25519KeyPair()
const dhPublicKey = await exportPublicKey(dhKeyPair.publicKey)
const peerKey = await importX25519PublicKey(peerPublicKey)
const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey)
const derivedKeys = await hkdfDerive(dhOutput, sharedKey, RATCHET_INFO, 64)
const rootKey = derivedKeys.slice(0, 32)
const sendingChainKey = derivedKeys.slice(32, 64)
return {
peer_id: peerId,
root_key: rootKey,
sending_chain_key: sendingChainKey,
receiving_chain_key: null,
dh_private_key: dhKeyPair,
dh_public_key: dhPublicKey,
dh_peer_public_key: peerPublicKey,
sending_message_number: 0,
receiving_message_number: 0,
previous_sending_chain_length: 0,
skipped_message_keys: new Map(),
}
}
export async function initializeRatchetReceiver(
peerId: string,
sharedKey: Uint8Array,
dhKeyPair: CryptoKeyPair
): Promise<DoubleRatchetState> {
const dhPublicKey = await exportPublicKey(dhKeyPair.publicKey)
return {
peer_id: peerId,
root_key: sharedKey,
sending_chain_key: new Uint8Array(0),
receiving_chain_key: null,
dh_private_key: dhKeyPair,
dh_public_key: dhPublicKey,
dh_peer_public_key: null,
sending_message_number: 0,
receiving_message_number: 0,
previous_sending_chain_length: 0,
skipped_message_keys: new Map(),
}
}
async function deriveMessageKey(chainKey: Uint8Array): Promise<{
messageKey: Uint8Array
nextChainKey: Uint8Array
}> {
const messageKey = await hmacSha256(chainKey, MESSAGE_KEY_BYTE)
const nextChainKey = await hmacSha256(chainKey, CHAIN_KEY_BYTE)
return {
messageKey: messageKey.slice(0, 32),
nextChainKey,
}
}
async function performDHRatchet(
state: DoubleRatchetState,
peerPublicKey: Uint8Array
): Promise<void> {
if (state.dh_private_key === null) {
throw new Error('DH private key not initialized')
}
state.previous_sending_chain_length = state.sending_message_number
state.sending_message_number = 0
state.receiving_message_number = 0
state.dh_peer_public_key = peerPublicKey
const peerKey = await importX25519PublicKey(peerPublicKey)
const dhOutput = await x25519DeriveSharedSecret(
state.dh_private_key.privateKey,
peerKey
)
const derivedKeys = await hkdfDerive(dhOutput, state.root_key, RATCHET_INFO, 64)
state.root_key = derivedKeys.slice(0, 32)
state.receiving_chain_key = derivedKeys.slice(32, 64)
const newDHKeyPair = await generateX25519KeyPair()
state.dh_private_key = newDHKeyPair
state.dh_public_key = await exportPublicKey(newDHKeyPair.publicKey)
const newDHOutput = await x25519DeriveSharedSecret(
newDHKeyPair.privateKey,
peerKey
)
const newDerivedKeys = await hkdfDerive(
newDHOutput,
state.root_key,
RATCHET_INFO,
64
)
state.root_key = newDerivedKeys.slice(0, 32)
state.sending_chain_key = newDerivedKeys.slice(32, 64)
}
async function skipMessageKeys(
state: DoubleRatchetState,
until: number
): Promise<void> {
if (state.receiving_chain_key === null || state.dh_peer_public_key === null)
return
if (until - state.receiving_message_number > MAX_SKIP_MESSAGE_KEYS) {
throw new Error('Too many skipped messages')
}
while (state.receiving_message_number < until) {
const { messageKey, nextChainKey } = await deriveMessageKey(
state.receiving_chain_key
)
const keyId = createSkippedKeyId(
state.dh_peer_public_key,
state.receiving_message_number
)
state.skipped_message_keys.set(keyId, messageKey)
state.receiving_chain_key = nextChainKey
state.receiving_message_number++
}
}
export async function encryptMessage(
state: DoubleRatchetState,
plaintext: Uint8Array,
associatedData?: Uint8Array
): Promise<EncryptedMessage> {
if (state.dh_public_key === null) {
throw new Error('DH public key not initialized')
}
const { messageKey, nextChainKey } = await deriveMessageKey(
state.sending_chain_key
)
const header: MessageHeader = {
dh_public_key: bytesToBase64(state.dh_public_key),
message_number: state.sending_message_number,
previous_chain_length: state.previous_sending_chain_length,
}
const headerBytes = new TextEncoder().encode(JSON.stringify(header))
const aad =
associatedData !== undefined
? concatBytes(associatedData, headerBytes)
: headerBytes
const { ciphertext, nonce } = await aesGcmEncrypt(messageKey, plaintext, aad)
state.sending_chain_key = nextChainKey
state.sending_message_number++
return {
ciphertext,
nonce,
header,
}
}
export async function decryptMessage(
state: DoubleRatchetState,
message: EncryptedMessage,
associatedData?: Uint8Array
): Promise<Uint8Array> {
const peerPublicKey = base64ToBytes(message.header.dh_public_key)
const skippedKeyId = createSkippedKeyId(
peerPublicKey,
message.header.message_number
)
const skippedKey = state.skipped_message_keys.get(skippedKeyId)
if (skippedKey !== undefined) {
state.skipped_message_keys.delete(skippedKeyId)
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
const aad =
associatedData !== undefined
? concatBytes(associatedData, headerBytes)
: headerBytes
return await aesGcmDecrypt(skippedKey, message.ciphertext, message.nonce, aad)
}
const isDifferentRatchetKey =
state.dh_peer_public_key === null ||
bytesToBase64(state.dh_peer_public_key) !== message.header.dh_public_key
if (isDifferentRatchetKey) {
if (state.receiving_chain_key !== null && state.dh_peer_public_key !== null) {
await skipMessageKeys(state, message.header.previous_chain_length)
}
await performDHRatchet(state, peerPublicKey)
}
await skipMessageKeys(state, message.header.message_number)
if (state.receiving_chain_key === null) {
throw new Error('Receiving chain key not initialized after ratchet')
}
const { messageKey, nextChainKey } = await deriveMessageKey(
state.receiving_chain_key
)
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
const aad =
associatedData !== undefined
? concatBytes(associatedData, headerBytes)
: headerBytes
const plaintext = await aesGcmDecrypt(
messageKey,
message.ciphertext,
message.nonce,
aad
)
state.receiving_chain_key = nextChainKey
state.receiving_message_number++
return plaintext
}
export async function serializeRatchetState(
state: DoubleRatchetState
): Promise<SerializedRatchetState> {
let dhPrivateKey: string | null = null
if (state.dh_private_key !== null) {
const privateKeyBytes = await exportPrivateKey(
state.dh_private_key.privateKey
)
dhPrivateKey = bytesToBase64(privateKeyBytes)
}
const skippedKeys: Record<string, string> = {}
for (const [key, value] of state.skipped_message_keys) {
skippedKeys[key] = bytesToBase64(value)
}
return {
peer_id: state.peer_id,
root_key: bytesToBase64(state.root_key),
sending_chain_key: bytesToBase64(state.sending_chain_key),
receiving_chain_key:
state.receiving_chain_key !== null
? bytesToBase64(state.receiving_chain_key)
: null,
dh_private_key: dhPrivateKey,
dh_public_key:
state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
dh_peer_public_key:
state.dh_peer_public_key !== null
? bytesToBase64(state.dh_peer_public_key)
: null,
sending_message_number: state.sending_message_number,
receiving_message_number: state.receiving_message_number,
previous_sending_chain_length: state.previous_sending_chain_length,
skipped_message_keys: skippedKeys,
}
}
export async function deserializeRatchetState(
serialized: SerializedRatchetState
): Promise<DoubleRatchetState> {
let dhPrivateKey: CryptoKeyPair | null = null
if (serialized.dh_private_key && serialized.dh_public_key) {
const privateKeyBytes = base64ToBytes(serialized.dh_private_key)
const publicKeyBytes = base64ToBytes(serialized.dh_public_key)
const privateKey = await importX25519PrivateKey(privateKeyBytes)
const publicKey = await importX25519PublicKey(publicKeyBytes)
dhPrivateKey = { privateKey, publicKey }
}
const skippedKeys = new Map<string, Uint8Array>()
for (const [key, value] of Object.entries(serialized.skipped_message_keys)) {
skippedKeys.set(key, base64ToBytes(value))
}
return {
peer_id: serialized.peer_id,
root_key: base64ToBytes(serialized.root_key),
sending_chain_key: base64ToBytes(serialized.sending_chain_key),
receiving_chain_key: serialized.receiving_chain_key
? base64ToBytes(serialized.receiving_chain_key)
: null,
dh_private_key: dhPrivateKey,
dh_public_key: serialized.dh_public_key
? base64ToBytes(serialized.dh_public_key)
: null,
dh_peer_public_key: serialized.dh_peer_public_key
? base64ToBytes(serialized.dh_peer_public_key)
: null,
sending_message_number: serialized.sending_message_number,
receiving_message_number: serialized.receiving_message_number,
previous_sending_chain_length: serialized.previous_sending_chain_length,
skipped_message_keys: skippedKeys,
}
}
export function serializeMessageHeader(header: MessageHeader): string {
return JSON.stringify(header)
}
export function deserializeMessageHeader(serialized: string): MessageHeader {
return JSON.parse(serialized) as MessageHeader
}
export function serializeEncryptedMessage(message: EncryptedMessage): string {
return JSON.stringify({
ciphertext: bytesToBase64(message.ciphertext),
nonce: bytesToBase64(message.nonce),
header: message.header,
})
}
interface SerializedEncryptedMessage {
ciphertext: string
nonce: string
header: MessageHeader
}
export function deserializeEncryptedMessage(
serialized: string
): EncryptedMessage {
const parsed = JSON.parse(serialized) as SerializedEncryptedMessage
return {
ciphertext: base64ToBytes(parsed.ciphertext),
nonce: base64ToBytes(parsed.nonce),
header: parsed.header,
}
}