fix: round 2 MLS security fixes — resolve critical key schedule and validation gaps
Critical fixes: - Fix PSK/ExternalInit proposals by Reference dropped from key schedule: processCommit now collects ALL resolved proposals (inline + by-reference) into resolvedProposals list used for PSK and ExternalInit computation - Fix decrypt() missing blank-leaf membership check: validate sender leaf is non-null (occupied) before proceeding with decryption High fixes: - Fix MlsGroupManager.decrypt() now mutex-protected to prevent concurrent SecretTree ratchet corruption and potential nonce reuse Medium fixes: - Fix externalJoin: verify GroupInfo signature before trusting tree/keys - Fix parentHash verification: COMMIT leaf nodes must have non-empty parentHash (no longer silently skipped) - Fix proposal application order: Updates/Removes applied before Adds per RFC 9420 §12.4.2 (frees blank slots before reuse) - Add encryption key uniqueness check in RatchetTree.addLeaf() per §7.3 - Add LeafNode capabilities validation: verify version and ciphersuite support in applyProposalAdd per §12.1.1 - Remove redundant confirmation tag recomputation in processCommit https://claude.ai/code/session_017SjKXS4Vpu4xRg9zHTgpmC
This commit is contained in:
+58
-27
@@ -356,20 +356,23 @@ class MlsGroup private constructor(
|
|||||||
// Check if we need an UpdatePath (required unless only SelfRemove)
|
// Check if we need an UpdatePath (required unless only SelfRemove)
|
||||||
val needsPath = proposals.any { it.proposal !is Proposal.SelfRemove }
|
val needsPath = proposals.any { it.proposal !is Proposal.SelfRemove }
|
||||||
|
|
||||||
// Apply proposals to tree FIRST (RFC 9420 Section 12.4.1)
|
// Apply proposals to tree FIRST (RFC 9420 Section 12.4.2)
|
||||||
// The UpdatePath is computed after proposals are applied.
|
// Order: Updates/Removes first, then Adds (so blank slots are freed before reuse)
|
||||||
val addedMembers = mutableListOf<Pair<Int, MlsKeyPackage>>()
|
val addedMembers = mutableListOf<Pair<Int, MlsKeyPackage>>()
|
||||||
|
val addProposals = mutableListOf<PendingProposal>()
|
||||||
for (pending in proposals) {
|
for (pending in proposals) {
|
||||||
val p = pending.proposal
|
if (pending.proposal is Proposal.Add) {
|
||||||
if (p is Proposal.Add) {
|
addProposals.add(pending)
|
||||||
// applyProposal calls tree.addLeaf() which returns the actual leaf index
|
|
||||||
// (may reuse a blank slot instead of appending)
|
|
||||||
val leafIndex = applyProposalAdd(p)
|
|
||||||
addedMembers.add(leafIndex to p.keyPackage)
|
|
||||||
} else {
|
} else {
|
||||||
applyProposal(p, pending.senderLeafIndex)
|
applyProposal(pending.proposal, pending.senderLeafIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Apply Adds after Removes/Updates
|
||||||
|
for (pending in addProposals) {
|
||||||
|
val p = pending.proposal as Proposal.Add
|
||||||
|
val leafIndex = applyProposalAdd(p)
|
||||||
|
addedMembers.add(leafIndex to p.keyPackage)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new path secrets on the updated tree
|
// Generate new path secrets on the updated tree
|
||||||
val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||||
@@ -623,10 +626,13 @@ class MlsGroup private constructor(
|
|||||||
val generation = senderReader.readUint32().toInt()
|
val generation = senderReader.readUint32().toInt()
|
||||||
val reuseGuard = senderReader.readBytes(REUSE_GUARD_LENGTH)
|
val reuseGuard = senderReader.readBytes(REUSE_GUARD_LENGTH)
|
||||||
|
|
||||||
// Validate sender leaf index (M12)
|
// Validate sender leaf index and membership (RFC 9420 §6.3.1)
|
||||||
require(senderLeafIndex in 0 until tree.leafCount) {
|
require(senderLeafIndex in 0 until tree.leafCount) {
|
||||||
"Sender leaf index $senderLeafIndex out of range [0, ${tree.leafCount})"
|
"Sender leaf index $senderLeafIndex out of range [0, ${tree.leafCount})"
|
||||||
}
|
}
|
||||||
|
require(tree.getLeaf(senderLeafIndex) != null) {
|
||||||
|
"Sender leaf is blank at index $senderLeafIndex (not a group member)"
|
||||||
|
}
|
||||||
|
|
||||||
// Get the key/nonce for this sender+generation
|
// Get the key/nonce for this sender+generation
|
||||||
// If we sent this message ourselves, use the cached key to avoid ratchet conflict
|
// If we sent this message ourselves, use the cached key to avoid ratchet conflict
|
||||||
@@ -692,10 +698,13 @@ class MlsGroup private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply proposals (resolve references from pending pool)
|
// Apply proposals (resolve references from pending pool)
|
||||||
|
// Collect all resolved proposals for key schedule computation
|
||||||
|
val resolvedProposals = mutableListOf<Proposal>()
|
||||||
for (proposalOrRef in commit.proposals) {
|
for (proposalOrRef in commit.proposals) {
|
||||||
when (proposalOrRef) {
|
when (proposalOrRef) {
|
||||||
is ProposalOrRef.Inline -> {
|
is ProposalOrRef.Inline -> {
|
||||||
applyProposal(proposalOrRef.proposal, senderLeafIndex)
|
applyProposal(proposalOrRef.proposal, senderLeafIndex)
|
||||||
|
resolvedProposals.add(proposalOrRef.proposal)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ProposalOrRef.Reference -> {
|
is ProposalOrRef.Reference -> {
|
||||||
@@ -711,6 +720,7 @@ class MlsGroup private constructor(
|
|||||||
"Commit references unknown proposal (ref not found in pending proposals)"
|
"Commit references unknown proposal (ref not found in pending proposals)"
|
||||||
}
|
}
|
||||||
applyProposal(resolved.proposal, resolved.senderLeafIndex)
|
applyProposal(resolved.proposal, resolved.senderLeafIndex)
|
||||||
|
resolvedProposals.add(resolved.proposal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -794,19 +804,12 @@ class MlsGroup private constructor(
|
|||||||
confirmedTranscriptHash = newConfirmedTranscriptHash,
|
confirmedTranscriptHash = newConfirmedTranscriptHash,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compute PSK secret from any PSK proposals in the commit
|
// Compute PSK secret from all resolved proposals (both inline and by-reference)
|
||||||
val commitPskProposals =
|
val pskSecret = computePskSecret(resolvedProposals)
|
||||||
commit.proposals.mapNotNull {
|
|
||||||
when (it) {
|
|
||||||
is ProposalOrRef.Inline -> it.proposal
|
|
||||||
is ProposalOrRef.Reference -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val pskSecret = computePskSecret(commitPskProposals)
|
|
||||||
|
|
||||||
// Check for ExternalInit proposal — overrides init_secret (RFC 9420 Section 8.3)
|
// Check for ExternalInit proposal — overrides init_secret (RFC 9420 Section 8.3)
|
||||||
val externalInitProposal =
|
val externalInitProposal =
|
||||||
commitPskProposals.filterIsInstance<Proposal.ExternalInit>().firstOrNull()
|
resolvedProposals.filterIsInstance<Proposal.ExternalInit>().firstOrNull()
|
||||||
val effectiveInitSecret =
|
val effectiveInitSecret =
|
||||||
if (externalInitProposal != null) {
|
if (externalInitProposal != null) {
|
||||||
deriveExternalInitSecret(externalInitProposal.kemOutput)
|
deriveExternalInitSecret(externalInitProposal.kemOutput)
|
||||||
@@ -825,11 +828,10 @@ class MlsGroup private constructor(
|
|||||||
"Confirmation tag verification failed"
|
"Confirmation tag verification failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update interim_transcript_hash for next epoch
|
// Update interim_transcript_hash for next epoch (reuse verified expectedTag)
|
||||||
val computedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
|
|
||||||
val interimInput = TlsWriter()
|
val interimInput = TlsWriter()
|
||||||
interimInput.putBytes(newConfirmedTranscriptHash)
|
interimInput.putBytes(newConfirmedTranscriptHash)
|
||||||
interimInput.putOpaqueVarInt(computedTag)
|
interimInput.putOpaqueVarInt(expectedTag)
|
||||||
interimTranscriptHash = MlsCryptoProvider.hash(interimInput.toByteArray())
|
interimTranscriptHash = MlsCryptoProvider.hash(interimInput.toByteArray())
|
||||||
|
|
||||||
pendingProposals.clear()
|
pendingProposals.clear()
|
||||||
@@ -996,8 +998,15 @@ class MlsGroup private constructor(
|
|||||||
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
||||||
if (directPath.isEmpty()) return true
|
if (directPath.isEmpty()) return true
|
||||||
|
|
||||||
val leafParentHash = updatePath.leafNode.parentHash ?: return true
|
// COMMIT leaf nodes MUST have a non-empty parentHash (RFC 9420 §7.9.2)
|
||||||
if (leafParentHash.isEmpty()) return true
|
val leafParentHash = updatePath.leafNode.parentHash
|
||||||
|
if (updatePath.leafNode.leafNodeSource == LeafNodeSource.COMMIT) {
|
||||||
|
requireNotNull(leafParentHash) { "Commit LeafNode missing parentHash" }
|
||||||
|
require(leafParentHash.isNotEmpty()) { "Commit LeafNode has empty parentHash" }
|
||||||
|
} else {
|
||||||
|
// Non-commit leaf nodes may not have parentHash
|
||||||
|
if (leafParentHash == null || leafParentHash.isEmpty()) return true
|
||||||
|
}
|
||||||
|
|
||||||
// Walk up the direct path, verifying each parent_hash link
|
// Walk up the direct path, verifying each parent_hash link
|
||||||
var expectedParentHash = leafParentHash
|
var expectedParentHash = leafParentHash
|
||||||
@@ -1129,14 +1138,27 @@ class MlsGroup private constructor(
|
|||||||
* Apply an Add proposal and return the assigned leaf index.
|
* Apply an Add proposal and return the assigned leaf index.
|
||||||
*/
|
*/
|
||||||
private fun applyProposalAdd(proposal: Proposal.Add): Int {
|
private fun applyProposalAdd(proposal: Proposal.Add): Int {
|
||||||
val lifetime = proposal.keyPackage.leafNode.lifetime
|
val leafNode = proposal.keyPackage.leafNode
|
||||||
|
|
||||||
|
// Validate lifetime
|
||||||
|
val lifetime = leafNode.lifetime
|
||||||
if (lifetime != null) {
|
if (lifetime != null) {
|
||||||
val now = TimeUtils.now()
|
val now = TimeUtils.now()
|
||||||
require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
|
require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
|
||||||
"KeyPackage lifetime expired or not yet valid"
|
"KeyPackage lifetime expired or not yet valid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tree.addLeaf(proposal.keyPackage.leafNode)
|
|
||||||
|
// Validate capabilities (RFC 9420 §12.1.1)
|
||||||
|
val caps = leafNode.capabilities
|
||||||
|
require(1 in caps.versions) {
|
||||||
|
"KeyPackage does not support MLS protocol version 1"
|
||||||
|
}
|
||||||
|
require(1 in caps.ciphersuites) {
|
||||||
|
"KeyPackage does not support ciphersuite 0x0001"
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree.addLeaf(leafNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyProposal(
|
private fun applyProposal(
|
||||||
@@ -1588,6 +1610,15 @@ class MlsGroup private constructor(
|
|||||||
?: throw IllegalArgumentException("GroupInfo missing ratchet_tree extension")
|
?: throw IllegalArgumentException("GroupInfo missing ratchet_tree extension")
|
||||||
val tree = RatchetTree.decodeTls(TlsReader(ratchetTreeExt.extensionData))
|
val tree = RatchetTree.decodeTls(TlsReader(ratchetTreeExt.extensionData))
|
||||||
|
|
||||||
|
// Verify GroupInfo signature (RFC 9420 Section 12.4.3.1)
|
||||||
|
val signerLeaf = tree.getLeaf(groupInfo.signer)
|
||||||
|
requireNotNull(signerLeaf) {
|
||||||
|
"Signer leaf is null at index ${groupInfo.signer} — cannot verify GroupInfo signature"
|
||||||
|
}
|
||||||
|
require(groupInfo.verifySignature(signerLeaf.signatureKey)) {
|
||||||
|
"Invalid GroupInfo signature in externalJoin"
|
||||||
|
}
|
||||||
|
|
||||||
// Extract external_pub from extensions
|
// Extract external_pub from extensions
|
||||||
val externalPubExt =
|
val externalPubExt =
|
||||||
groupInfo.extensions.find { it.extensionType == EXTERNAL_PUB_EXTENSION_TYPE }
|
groupInfo.extensions.find { it.extensionType == EXTERNAL_PUB_EXTENSION_TYPE }
|
||||||
|
|||||||
+15
-14
@@ -292,24 +292,25 @@ class MlsGroupManager(
|
|||||||
suspend fun decrypt(
|
suspend fun decrypt(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
messageBytes: ByteArray,
|
messageBytes: ByteArray,
|
||||||
): DecryptedMessage {
|
): DecryptedMessage =
|
||||||
val group = requireGroup(nostrGroupId)
|
mutex.withLock {
|
||||||
|
val group = requireGroup(nostrGroupId)
|
||||||
|
|
||||||
// Try current epoch
|
// Try current epoch
|
||||||
val current = group.decryptOrNull(messageBytes)
|
val current = group.decryptOrNull(messageBytes)
|
||||||
if (current != null) return current
|
if (current != null) return@withLock current
|
||||||
|
|
||||||
// Try retained epochs
|
// Try retained epochs
|
||||||
val retained = retainedEpochs[nostrGroupId] ?: emptyList()
|
val retained = retainedEpochs[nostrGroupId] ?: emptyList()
|
||||||
for (epochSecrets in retained) {
|
for (epochSecrets in retained) {
|
||||||
val result = tryDecryptWithRetainedEpoch(messageBytes, epochSecrets)
|
val result = tryDecryptWithRetainedEpoch(messageBytes, epochSecrets)
|
||||||
if (result != null) return result
|
if (result != null) return@withLock result
|
||||||
|
}
|
||||||
|
|
||||||
|
// No epoch could decrypt — rethrow from current epoch for diagnostics
|
||||||
|
group.decrypt(messageBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No epoch could decrypt — rethrow from current epoch for diagnostics
|
|
||||||
return group.decrypt(messageBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt with null return on failure.
|
* Decrypt with null return on failure.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -100,11 +100,28 @@ class RatchetTree(
|
|||||||
return (node as? TreeNode.Leaf)?.leafNode
|
return (node as? TreeNode.Leaf)?.leafNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the given encryption key is not already used by another leaf (RFC 9420 §7.3).
|
||||||
|
*/
|
||||||
|
private fun requireUniqueEncryptionKey(
|
||||||
|
leafNode: LeafNode,
|
||||||
|
excludeLeafIndex: Int = -1,
|
||||||
|
) {
|
||||||
|
for (i in 0 until _leafCount) {
|
||||||
|
if (i == excludeLeafIndex) continue
|
||||||
|
val existing = getLeaf(i) ?: continue
|
||||||
|
require(!existing.encryptionKey.contentEquals(leafNode.encryptionKey)) {
|
||||||
|
"Duplicate encryption key: leaf $i already uses this key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new leaf to the tree. Returns the leaf index.
|
* Add a new leaf to the tree. Returns the leaf index.
|
||||||
* First tries to reuse a blank leaf slot, otherwise appends.
|
* First tries to reuse a blank leaf slot, otherwise appends.
|
||||||
*/
|
*/
|
||||||
fun addLeaf(leafNode: LeafNode): Int {
|
fun addLeaf(leafNode: LeafNode): Int {
|
||||||
|
requireUniqueEncryptionKey(leafNode)
|
||||||
// Find first blank leaf
|
// Find first blank leaf
|
||||||
for (i in 0 until _leafCount) {
|
for (i in 0 until _leafCount) {
|
||||||
if (getLeaf(i) == null) {
|
if (getLeaf(i) == null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user