498 lines
17 KiB
Dart
498 lines
17 KiB
Dart
///
|
||
/// 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);
|
||
}
|
||
}
|
||
}
|