Add mutex-based transaction safety to Signal key store (re-reworked) (#1697)
* mutex reimplementation * lint * lint * lint fix * Add cleanup for expired sender key mutexes Introduces a mechanism to periodically clean up unused sender key mutexes in addTransactionCapability, reducing memory usage by removing mutexes that have not been used for over an hour and are not locked. A timer is started when the first mutex is created, and cleanup runs every 30 minutes. * Update auth-utils.ts * Refactor Signal key transaction usage Introduces a local variable for parsed Signal keys in libsignal.ts to avoid repeated type assertions and improve code clarity. * Refactor transaction handling for key operations Introduces per-entity mutexes and passes key identifiers to transaction calls for finer-grained concurrency control in Signal key storage and socket operations. Updates transaction signatures and usage across Signal, Socket, and Utils modules to improve atomicity and performance, especially for system and sync messages. * Improve SignalKeyStore transaction handling Refactored transaction logic in SignalKeyStore to add commit retries, cleanup of transaction state, and improved mutex management for sender keys. Enhanced validation and batching for pre-key deletions and updates, improving concurrency and reliability. * Replace custom mutex cleanup with LRU cache Switched from manual mutex cleanup logic to using the lru-cache package for managing mutexes in addTransactionCapability. This simplifies resource management and ensures expired mutexes are automatically purged. Added lru-cache as a dependency. * Lint fix * Update src/Signal/libsignal.ts * Update libsignal.ts --------- Co-authored-by: João Lucas <jlucaso@hotmail.com> Co-authored-by: Rajeh Taher <rajeh@reforward.dev>
This commit is contained in:
committed by
GitHub
parent
ae0cb89714
commit
f83a1c2aff
@@ -1,6 +1,5 @@
|
||||
/* @ts-ignore */
|
||||
import { decrypt, encrypt } from 'libsignal/src/crypto'
|
||||
import queueJob from './queue-job'
|
||||
import { SenderKeyMessage } from './sender-key-message'
|
||||
import { SenderKeyName } from './sender-key-name'
|
||||
import { SenderKeyRecord } from './sender-key-record'
|
||||
@@ -8,6 +7,7 @@ import { SenderKeyState } from './sender-key-state'
|
||||
|
||||
export interface SenderKeyStore {
|
||||
loadSenderKey(senderKeyName: SenderKeyName): Promise<SenderKeyRecord>
|
||||
|
||||
storeSenderKey(senderKeyName: SenderKeyName, record: SenderKeyRecord): Promise<void>
|
||||
}
|
||||
|
||||
@@ -20,64 +20,56 @@ export class GroupCipher {
|
||||
this.senderKeyName = senderKeyName
|
||||
}
|
||||
|
||||
private queueJob<T>(awaitable: () => Promise<T>): Promise<T> {
|
||||
return queueJob(this.senderKeyName.toString(), awaitable)
|
||||
}
|
||||
|
||||
public async encrypt(paddedPlaintext: Uint8Array | string): Promise<Uint8Array> {
|
||||
return await this.queueJob(async () => {
|
||||
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName)
|
||||
if (!record) {
|
||||
throw new Error('No SenderKeyRecord found for encryption')
|
||||
}
|
||||
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName)
|
||||
if (!record) {
|
||||
throw new Error('No SenderKeyRecord found for encryption')
|
||||
}
|
||||
|
||||
const senderKeyState = record.getSenderKeyState()
|
||||
if (!senderKeyState) {
|
||||
throw new Error('No session to encrypt message')
|
||||
}
|
||||
const senderKeyState = record.getSenderKeyState()
|
||||
if (!senderKeyState) {
|
||||
throw new Error('No session to encrypt message')
|
||||
}
|
||||
|
||||
const iteration = senderKeyState.getSenderChainKey().getIteration()
|
||||
const senderKey = this.getSenderKey(senderKeyState, iteration === 0 ? 0 : iteration + 1)
|
||||
const iteration = senderKeyState.getSenderChainKey().getIteration()
|
||||
const senderKey = this.getSenderKey(senderKeyState, iteration === 0 ? 0 : iteration + 1)
|
||||
|
||||
const ciphertext = await this.getCipherText(senderKey.getIv(), senderKey.getCipherKey(), paddedPlaintext)
|
||||
const ciphertext = await this.getCipherText(senderKey.getIv(), senderKey.getCipherKey(), paddedPlaintext)
|
||||
|
||||
const senderKeyMessage = new SenderKeyMessage(
|
||||
senderKeyState.getKeyId(),
|
||||
senderKey.getIteration(),
|
||||
ciphertext,
|
||||
senderKeyState.getSigningKeyPrivate()
|
||||
)
|
||||
const senderKeyMessage = new SenderKeyMessage(
|
||||
senderKeyState.getKeyId(),
|
||||
senderKey.getIteration(),
|
||||
ciphertext,
|
||||
senderKeyState.getSigningKeyPrivate()
|
||||
)
|
||||
|
||||
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record)
|
||||
return senderKeyMessage.serialize()
|
||||
})
|
||||
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record)
|
||||
return senderKeyMessage.serialize()
|
||||
}
|
||||
|
||||
public async decrypt(senderKeyMessageBytes: Uint8Array): Promise<Uint8Array> {
|
||||
return await this.queueJob(async () => {
|
||||
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName)
|
||||
if (!record) {
|
||||
throw new Error('No SenderKeyRecord found for decryption')
|
||||
}
|
||||
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName)
|
||||
if (!record) {
|
||||
throw new Error('No SenderKeyRecord found for decryption')
|
||||
}
|
||||
|
||||
const senderKeyMessage = new SenderKeyMessage(null, null, null, null, senderKeyMessageBytes)
|
||||
const senderKeyState = record.getSenderKeyState(senderKeyMessage.getKeyId())
|
||||
if (!senderKeyState) {
|
||||
throw new Error('No session found to decrypt message')
|
||||
}
|
||||
const senderKeyMessage = new SenderKeyMessage(null, null, null, null, senderKeyMessageBytes)
|
||||
const senderKeyState = record.getSenderKeyState(senderKeyMessage.getKeyId())
|
||||
if (!senderKeyState) {
|
||||
throw new Error('No session found to decrypt message')
|
||||
}
|
||||
|
||||
senderKeyMessage.verifySignature(senderKeyState.getSigningKeyPublic())
|
||||
const senderKey = this.getSenderKey(senderKeyState, senderKeyMessage.getIteration())
|
||||
senderKeyMessage.verifySignature(senderKeyState.getSigningKeyPublic())
|
||||
const senderKey = this.getSenderKey(senderKeyState, senderKeyMessage.getIteration())
|
||||
|
||||
const plaintext = await this.getPlainText(
|
||||
senderKey.getIv(),
|
||||
senderKey.getCipherKey(),
|
||||
senderKeyMessage.getCipherText()
|
||||
)
|
||||
const plaintext = await this.getPlainText(
|
||||
senderKey.getIv(),
|
||||
senderKey.getCipherKey(),
|
||||
senderKeyMessage.getCipherText()
|
||||
)
|
||||
|
||||
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record)
|
||||
return plaintext
|
||||
})
|
||||
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record)
|
||||
return plaintext
|
||||
}
|
||||
|
||||
private getSenderKey(senderKeyState: SenderKeyState, iteration: number) {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
interface QueueJob<T> {
|
||||
awaitable: () => Promise<T>
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
const _queueAsyncBuckets = new Map<string | number, Array<QueueJob<any>>>()
|
||||
|
||||
export function cleanupQueues() {
|
||||
_queueAsyncBuckets.clear()
|
||||
}
|
||||
|
||||
const _gcLimit = 10000
|
||||
|
||||
async function _asyncQueueExecutor(queue: Array<QueueJob<any>>, cleanup: () => void): Promise<void> {
|
||||
let offt = 0
|
||||
|
||||
while (true) {
|
||||
const limit = Math.min(queue.length, _gcLimit)
|
||||
for (let i = offt; i < limit; i++) {
|
||||
const job = queue[i]!
|
||||
try {
|
||||
job.resolve(await job.awaitable())
|
||||
} catch (e) {
|
||||
job.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (limit < queue.length) {
|
||||
if (limit >= _gcLimit) {
|
||||
queue.splice(0, limit)
|
||||
offt = 0
|
||||
} else {
|
||||
offt = limit
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
}
|
||||
|
||||
export default function queueJob<T>(bucket: string | number, awaitable: () => Promise<T>): Promise<T> {
|
||||
// Skip name assignment since it's readonly in strict mode
|
||||
if (typeof bucket !== 'string') {
|
||||
console.warn('Unhandled bucket type (for naming):', typeof bucket, bucket)
|
||||
}
|
||||
|
||||
let inactive = false
|
||||
if (!_queueAsyncBuckets.has(bucket)) {
|
||||
_queueAsyncBuckets.set(bucket, [])
|
||||
inactive = true
|
||||
}
|
||||
|
||||
const queue = _queueAsyncBuckets.get(bucket)!
|
||||
const job = new Promise<T>((resolve, reject) => {
|
||||
queue.push({
|
||||
awaitable,
|
||||
resolve: resolve as (value: any) => void,
|
||||
reject
|
||||
})
|
||||
})
|
||||
|
||||
if (inactive) {
|
||||
_asyncQueueExecutor(queue, () => _queueAsyncBuckets.delete(bucket))
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
||||
Reference in New Issue
Block a user