lum_ccc_rust/flutter_src/ccc/ratchet_engine.dart

498 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

///
/// 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<int> 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<int>? 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<RatchetState> initializeFromRoot({
required List<int> 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<int>.from(rootKey), // TODO: encrypt under device master key
chainKey: chainKey,
channelUuid: channelUuid,
senderIdentifier: senderIdentifier,
);
// Genesis checkpoint
state.checkpoints[0] = List<int>.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<RatchetAdvanceResult> 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<int>? 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<RatchetAdvanceResult> 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<RatchetAdvanceResult> deriveKeyForOldMessage({
required RatchetState state,
required CCCData cccData,
required int targetCounter,
}) async {
// Find nearest checkpoint at or before targetCounter
int checkpointCounter = 0;
List<int> chainKey = List<int>.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<int>.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<int>.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<List<int>> 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<int>.from(publicKeyBytes);
state.lastAsymmetricRatchetCounter = state.lastSentCounter;
return List<int>.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<void> applyRemoteAsymmetricRatchet({
required RatchetState state,
required CCCData cccData,
required List<int> 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<int>.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<List<int>> _kdf(
CCCData cccData,
List<int> 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<List<int>> _deriveMessageKey(
CCCData cccData,
List<int> 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<List<int>> _hashBytes(
List<int> 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<int> _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<int, List<int>> checkpoints,
int counter,
List<int> chainKey,
int checkpointInterval,
) {
final interval =
checkpointInterval > 0 ? checkpointInterval : defaultCheckpointInterval;
if (counter % interval == 0) {
checkpoints[counter] = List<int>.from(chainKey);
}
}
}