fix(mls): check LeafNode lifetimes against the policy's clock

The vendored ts-mls KeyPackages are valid 2026-09-17 to 2026-10-03, and
MlsGroup checked lifetimes against wall time, so CordnLifecycleInteropTest
and KotlinArtifactProducerTest started failing on 2026-10-04 - and the
pre-push hook with them, for every push.

MlsGroupPolicy gains now(), defaulting to TimeUtils.now(); the two lifetime
checks (UpdatePath leaves and Add proposals) read it. The fixture tests run
under CordnGroupPolicy with the clock pinned inside the fixtures' window,
since ts-mls signed those lifetimes and they cannot be extended here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-10-04 15:38:55 +00:00
parent 2fe82b5c1a
commit 19bc32d6c4
5 changed files with 28 additions and 4 deletions
@@ -2001,7 +2001,7 @@ class MlsGroup private constructor(
// the signer authorized. // the signer authorized.
val lifetime = updatePath.leafNode.lifetime val lifetime = updatePath.leafNode.lifetime
if (lifetime != null) { if (lifetime != null) {
val now = TimeUtils.now() val now = policy.now()
require(now >= lifetime.notBefore && now <= lifetime.notAfter) { require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
"LeafNode lifetime expired or not yet valid in UpdatePath" "LeafNode lifetime expired or not yet valid in UpdatePath"
} }
@@ -2809,7 +2809,7 @@ class MlsGroup private constructor(
// Validate lifetime // Validate lifetime
val lifetime = leafNode.lifetime val lifetime = leafNode.lifetime
if (lifetime != null) { if (lifetime != null) {
val now = TimeUtils.now() val now = policy.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"
} }
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.mls.group
import com.vitorpamplona.quartz.mls.messages.CommitResult import com.vitorpamplona.quartz.mls.messages.CommitResult
import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.mls.tree.Capabilities
import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.mls.tree.Extension
import com.vitorpamplona.quartz.utils.TimeUtils
/** /**
* The application's rules about who in a group may do what. * The application's rules about who in a group may do what.
@@ -128,6 +129,14 @@ interface MlsGroupPolicy {
*/ */
val knownExtensionTypes: Set<Int> get() = emptySet() val knownExtensionTypes: Set<Int> get() = emptySet()
/**
* The clock, in unix seconds, that LeafNode lifetimes (RFC 9420 §7.2) are
* checked against. Wall time in production; a test pins it to a moment
* inside a recorded fixture's validity window, so fixtures signed by
* another implementation don't expire out from under the suite.
*/
fun now(): Long = TimeUtils.now()
companion object { companion object {
/** RFC 9420 exactly as written: any member may commit anything valid. */ /** RFC 9420 exactly as written: any member may commit anything valid. */
val Permissive: MlsGroupPolicy = object : MlsGroupPolicy {} val Permissive: MlsGroupPolicy = object : MlsGroupPolicy {}
@@ -260,7 +260,7 @@ class CordnLifecycleInteropTest {
val group = val group =
MlsGroup.create( MlsGroup.create(
identity = CordnCredential.of(alice).identity, identity = CordnCredential.of(alice).identity,
policy = CordnGroupPolicy, policy = TsMlsFixtures.cordnPolicy,
initialExtensions = listOf(CordnGroupMetadata(name = "from Kotlin").toExtension()), initialExtensions = listOf(CordnGroupMetadata(name = "from Kotlin").toExtension()),
) )
@@ -83,7 +83,7 @@ class KotlinArtifactProducerTest {
val group = val group =
MlsGroup.create( MlsGroup.create(
identity = CordnCredential.of(alice).identity, identity = CordnCredential.of(alice).identity,
policy = CordnGroupPolicy, policy = TsMlsFixtures.cordnPolicy,
initialExtensions = listOf(metadata.toExtension()), initialExtensions = listOf(metadata.toExtension()),
) )
write("k-alice.pk", alice) write("k-alice.pk", alice)
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.quartz.cordn.interop package com.vitorpamplona.quartz.cordn.interop
import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy
import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.mls.codec.TlsReader
import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.mls.codec.TlsWriter
import com.vitorpamplona.quartz.mls.crypto.Ed25519 import com.vitorpamplona.quartz.mls.crypto.Ed25519
import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
@@ -43,6 +45,19 @@ object TsMlsFixtures {
fun b64(name: String): ByteArray = Base64.decode(text(name)) fun b64(name: String): ByteArray = Base64.decode(text(name))
fun hex(name: String): ByteArray = text(name).chunked(2).map { it.toInt(16).toByte() }.toByteArray() fun hex(name: String): ByteArray = text(name).chunked(2).map { it.toInt(16).toByte() }.toByteArray()
/**
* 2026-09-21T09:46:40Z, inside the fixtures' KeyPackage lifetime
* (2026-09-17T03:34:16Z to 2026-10-03T08:34:16Z). ts-mls signed those
* lifetimes, so they cannot be extended here; the clock moves instead.
*/
const val NOW: Long = 1_790_000_000L
/** [CordnGroupPolicy] with its clock pinned to [NOW]. */
val cordnPolicy: MlsGroupPolicy =
object : MlsGroupPolicy by CordnGroupPolicy {
override fun now(): Long = NOW
}
} }
/** /**