/// /// Ratchet Engine – Double Ratchet Core for Normal Mode /// /// Pure-logic class with static methods that operate on [RatchetState] + /// [CCCData]. Designed to run on the background isolate alongside the /// existing CCC cipher chain. /// /// Phase 2 of the Hydra Apocalypse Ratchet. /// /// ## Per-Sender Chain Separation /// /// Each participant holds a separate [RatchetState] per sender in the /// channel. The chain key is derived as: /// /// ```text /// rootKey /// └── KDF("chain|channelUuid|senderIdentifier") → initial chainKey /// /// For each message: /// chainKey = KDF(chainKey, "next|channelUuid") // symmetric ratchet step /// messageKey = KDF(chainKey, counter + channelUuid) // per-message key /// ``` /// /// Including `senderIdentifier` in the initial derivation guarantees that /// two senders sharing the same root key and channel produce different /// chains, preventing key reuse when both send at the same counter. import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:letusmsg/ccc/ccc_kdf.dart'; import 'package:letusmsg/data/ccc_data.dart'; import 'package:letusmsg/data/ratchet_state_data.dart'; /// Result of a ratchet advance operation. /// /// Contains the derived 32-byte base message key and the message counter /// it corresponds to. class RatchetAdvanceResult { /// The derived 32-byte base message key for AEAD encryption/decryption. /// /// This key is passed into the isolate worker as the root key for /// per-layer key derivation (replacing the Phase 1 static root key). final List baseMessageKey; /// The message counter this key corresponds to. final int counter; /// Ephemeral X25519 public key bytes (32 bytes) when an asymmetric ratchet /// step was performed on this message. The caller must embed this in the /// encrypted payload (``rk`` field) so receivers can apply the same reset. /// /// Null when no asymmetric ratchet step was triggered. final List? asymmetricRatchetPublicKey; const RatchetAdvanceResult({ required this.baseMessageKey, required this.counter, this.asymmetricRatchetPublicKey, }); } /// Core ratchet engine implementing the symmetric Double Ratchet. /// /// All methods are static and pure — they take [RatchetState] and [CCCData] /// as input, mutate the state in place, and return derived key material. /// /// The engine does **not** handle persistence; callers must save the updated /// [RatchetState] via [RatchetStateManager] after each operation. /// /// ## Key Derivation Chain /// /// ```text /// rootKey /// └── KDF("chain|channelUuid|senderIdentifier") → initial chainKey /// /// For each message: /// chainKey = KDF(chainKey, "next|channelUuid") // symmetric ratchet step /// messageKey = KDF(chainKey, counter + channelUuid) // per-message key /// ``` class RatchetEngine { /// Default checkpoint interval (messages between chain key snapshots). static const int defaultCheckpointInterval = 500; /// Default asymmetric ratchet frequency (messages between KEM steps). static const int defaultRatchetFrequency = 50; // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- /// Initialize ratchet state from a shared root secret. /// /// Called once when a channel is created or an invite is accepted. /// All parties must call this with the same [rootKey] to establish /// identical starting states — critical for N-party group channels. /// /// Each participant creates one state per sender (including themselves). /// The [senderIdentifier] is mixed into the chain derivation so that /// two senders sharing the same root and channel produce **different** /// chain keys — preventing key reuse when both send the same counter. /// /// The [rootKey] should be a 32-byte cryptographically random secret /// shared securely during the invite flow. /// /// Returns a fully initialized [RatchetState] with: /// - A chain key unique to this (channel, sender) pair /// - Counters at 0 /// - Genesis checkpoint at counter 0 static Future initializeFromRoot({ required List rootKey, required String channelUuid, required String senderIdentifier, required CCCData cccData, }) async { // Derive the chain key from root with sender-specific domain separation. // Including senderIdentifier ensures that two participants sharing the // same rootKey + channelUuid produce different chains, eliminating any // key-reuse risk when both send at the same counter value. final chainKey = await _kdf( cccData, rootKey, 'chain|$channelUuid|$senderIdentifier', ); final state = RatchetState( encryptedRootKey: List.from(rootKey), // TODO: encrypt under device master key chainKey: chainKey, channelUuid: channelUuid, senderIdentifier: senderIdentifier, ); // Genesis checkpoint state.checkpoints[0] = List.from(chainKey); return state; } // --------------------------------------------------------------------------- // Symmetric Chain Advance // --------------------------------------------------------------------------- /// Advance the chain and derive the base message key for sending. /// /// This is the "click" of the ratchet. When /// [CCCData.symmetricRatchetEveryMessage] is true (default), the chain key /// is replaced with its KDF successor and the old value is permanently lost. /// /// The returned [RatchetAdvanceResult.baseMessageKey] is a unique 32-byte /// key for this specific message, suitable for passing into the isolate /// worker as the root key for per-layer AEAD derivation. /// /// **Side effects**: mutates [state] (chain key, counter, checkpoints). /// Caller must persist the updated state. static Future advanceSending({ required RatchetState state, required CCCData cccData, }) async { final counter = state.lastSentCounter + 1; // Symmetric ratchet step: derive new chain key from current if (cccData.symmetricRatchetEveryMessage) { state.chainKey = await _kdf( cccData, state.chainKey, 'next|${state.channelUuid}', ); } // Derive per-message base key final baseMessageKey = await _deriveMessageKey( cccData, state.chainKey, counter, state.channelUuid, ); // Update counter state.lastSentCounter = counter; // Checkpoint if needed _maybeCheckpoint( state.checkpoints, counter, state.chainKey, cccData.checkpointInterval, ); // Asymmetric ratchet step: generates fresh X25519 keypair and mixes // entropy into chain. The returned public key must be embedded in the // message payload ("rk" field) so receivers can apply the same reset. List? asymmetricPublicKey; if (_isAsymmetricRatchetDue(counter, cccData.ratchetFrequency)) { asymmetricPublicKey = await performAsymmetricRatchet( state: state, cccData: cccData, ); } return RatchetAdvanceResult( baseMessageKey: baseMessageKey, counter: counter, asymmetricRatchetPublicKey: asymmetricPublicKey, ); } /// Advance the chain to match the incoming message counter /// and derive the base message key for decryption. /// /// The chain is fast-forwarded from its current position to /// [incomingCounter], applying the symmetric ratchet step at each /// intermediate position. /// /// **Side effects**: mutates [state] (chain key, counter, checkpoints). /// Caller must persist the updated state. static Future advanceReceiving({ required RatchetState state, required CCCData cccData, required int incomingCounter, }) async { // Fast-forward chain to match incoming counter while (state.lastReceivedCounter < incomingCounter) { if (cccData.symmetricRatchetEveryMessage) { state.chainKey = await _kdf( cccData, state.chainKey, 'next|${state.channelUuid}', ); } state.lastReceivedCounter += 1; // Checkpoint at intermediate steps too _maybeCheckpoint( state.checkpoints, state.lastReceivedCounter, state.chainKey, cccData.checkpointInterval, ); } // Derive per-message base key final baseMessageKey = await _deriveMessageKey( cccData, state.chainKey, incomingCounter, state.channelUuid, ); return RatchetAdvanceResult( baseMessageKey: baseMessageKey, counter: incomingCounter, ); } // --------------------------------------------------------------------------- // Old Message Decryption (Random Access via Checkpoints) // --------------------------------------------------------------------------- /// Derive the base message key for an older message by replaying the chain /// from the nearest checkpoint. /// /// This is the "scroll up" scenario — the user wants to read a message /// that was received in the past. Instead of replaying from genesis /// (which could be millions of steps), we start from the nearest saved /// checkpoint and replay at most [checkpointInterval] steps. /// /// **Does not mutate** the main chain keys or counters in [state]. /// May add new checkpoints to [state.checkpoints]. static Future deriveKeyForOldMessage({ required RatchetState state, required CCCData cccData, required int targetCounter, }) async { // Find nearest checkpoint at or before targetCounter int checkpointCounter = 0; List chainKey = List.from(state.checkpoints[0] ?? state.chainKey); final sortedKeys = state.checkpoints.keys.toList()..sort(); for (final cp in sortedKeys) { if (cp <= targetCounter) { checkpointCounter = cp; chainKey = List.from(state.checkpoints[cp]!); } else { break; } } // Replay chain forward from checkpoint to target int current = checkpointCounter; while (current < targetCounter) { if (cccData.symmetricRatchetEveryMessage) { chainKey = await _kdf( cccData, chainKey, 'next|${state.channelUuid}', ); } current += 1; } // Derive base_message_key at target final baseMessageKey = await _deriveMessageKey( cccData, chainKey, targetCounter, state.channelUuid, ); // Optionally save a new checkpoint final interval = cccData.checkpointInterval > 0 ? cccData.checkpointInterval : defaultCheckpointInterval; if (targetCounter % interval == 0 && !state.checkpoints.containsKey(targetCounter)) { state.checkpoints[targetCounter] = List.from(chainKey); } return RatchetAdvanceResult( baseMessageKey: baseMessageKey, counter: targetCounter, ); } // --------------------------------------------------------------------------- // Asymmetric Ratchet (KEM) – Phase 3 X25519 // --------------------------------------------------------------------------- /// Perform an asymmetric ratchet step on the **sending** chain. /// /// Generates a fresh X25519 keypair and mixes the public key bytes into /// the chain key via KDF. The public key is returned so the caller can /// embed it in the encrypted payload (``rk`` field). All receivers /// extract those same bytes and call [applyRemoteAsymmetricRatchet] to /// perform the identical chain reset. /// /// ```text /// freshKeyPair = X25519.newKeyPair() /// publicKeyBytes = freshKeyPair.publicKey.bytes // 32 bytes /// /// new_chain_key = KDF( /// old_chain_key || publicKeyBytes, /// "asymmetric-reset|" + channelUuid, /// ) /// ``` /// /// The DH private key is persisted in [state] for potential future /// pairwise DH key agreement (Phase 4 enhancement for 1-to-1 channels). /// /// Returns the 32-byte X25519 public key that must be embedded in the /// message payload. static Future> performAsymmetricRatchet({ required RatchetState state, required CCCData cccData, }) async { // 1. Generate fresh X25519 keypair (32 bytes, OS-level CSPRNG) final x25519 = X25519(); final keyPair = await x25519.newKeyPair(); final publicKey = await keyPair.extractPublicKey(); final privateKeyBytes = await keyPair.extractPrivateKeyBytes(); final publicKeyBytes = publicKey.bytes; // 2. Mix public-key entropy into the chain // Using the public key ensures all receivers (group-compatible) can // compute the same new chain key from information in the payload. state.chainKey = await _kdf( cccData, [...state.chainKey, ...publicKeyBytes], 'asymmetric-reset|${state.channelUuid}', ); // 3. Persist DH keys for future pairwise DH (Phase 4) state.dhPrivateKey = privateKeyBytes; state.dhPublicKey = List.from(publicKeyBytes); state.lastAsymmetricRatchetCounter = state.lastSentCounter; return List.from(publicKeyBytes); } /// Apply a remote asymmetric ratchet step on the **receiving** chain. /// /// Called by the message processor after successful decryption when /// the payload contains an ``rk`` (ratchet-key) field. Applies the /// same chain-key mixing as [performAsymmetricRatchet] so sender and /// receiver chains stay in sync. /// /// [remotePublicKeyBytes] is the 32-byte X25519 public key extracted /// from the decrypted payload. static Future applyRemoteAsymmetricRatchet({ required RatchetState state, required CCCData cccData, required List remotePublicKeyBytes, }) async { // Mix the same public-key entropy that the sender used state.chainKey = await _kdf( cccData, [...state.chainKey, ...remotePublicKeyBytes], 'asymmetric-reset|${state.channelUuid}', ); // Store remote public key for future DH (Phase 4) state.remoteDhPublicKey = List.from(remotePublicKeyBytes); state.lastAsymmetricRatchetCounter = state.lastReceivedCounter; } /// Check whether an asymmetric ratchet step is due at the given counter. static bool _isAsymmetricRatchetDue(int counter, int ratchetFrequency) { if (ratchetFrequency <= 0) return false; // disabled or every-message (handled separately) return counter > 0 && counter % ratchetFrequency == 0; } // --------------------------------------------------------------------------- // Key Derivation Functions (Internal) // --------------------------------------------------------------------------- /// Core KDF: derive 32-byte key material from input key + info string. /// /// Uses the KDF function selected in [CCCData.kdfFunction] /// (SHA-256/384/512/BLAKE2b-512). Output is always truncated to 32 bytes. static Future> _kdf( CCCData cccData, List inputKey, String info, ) async { final kdfFunction = cccKdfFunctionFromInt( normalizeCccKdfFunctionValue(cccData.kdfFunction), ); final hashInput = [...inputKey, ...utf8.encode(info)]; final fullHash = await _hashBytes(hashInput, kdfFunction); return fullHash.sublist(0, 32); // truncate to 32 bytes } /// Derive per-message encryption key from chain key + counter + channel ID. /// /// Formula: ``KDF(chainKey, counter_bytes(8) || channelUuid)`` /// /// The counter is encoded as 8-byte big-endian to ensure deterministic /// byte representation across platforms. static Future> _deriveMessageKey( CCCData cccData, List chainKey, int counter, String channelUuid, ) async { final counterBytes = _intToBytes8(counter); final info = '$channelUuid|msg|${String.fromCharCodes(counterBytes)}'; return _kdf(cccData, chainKey, info); } /// Hash bytes using the specified KDF/hash function. static Future> _hashBytes( List bytes, CccKdfFunction kdfFunction, ) async { switch (kdfFunction) { case CccKdfFunction.sha256: return (await Sha256().hash(bytes)).bytes; case CccKdfFunction.sha384: return (await Sha384().hash(bytes)).bytes; case CccKdfFunction.sha512: return (await Sha512().hash(bytes)).bytes; case CccKdfFunction.blake2b512: return (await Blake2b().hash(bytes)).bytes; } } /// Convert an integer to 8-byte big-endian representation. static List _intToBytes8(int value) { return [ (value >> 56) & 0xFF, (value >> 48) & 0xFF, (value >> 40) & 0xFF, (value >> 32) & 0xFF, (value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF, ]; } // --------------------------------------------------------------------------- // Checkpoint Helpers // --------------------------------------------------------------------------- /// Save a checkpoint if the counter is at a checkpoint boundary. static void _maybeCheckpoint( Map> checkpoints, int counter, List chainKey, int checkpointInterval, ) { final interval = checkpointInterval > 0 ? checkpointInterval : defaultCheckpointInterval; if (counter % interval == 0) { checkpoints[counter] = List.from(chainKey); } } }