From 220e087a721e1f70e2ace1bbb56324e80a89a407 Mon Sep 17 00:00:00 2001 From: jeremyd <4072+jeremyd@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:33:48 +0000 Subject: [PATCH] feat(marmot): optional leaf lifetime on createKeyPackage createKeyPackage always stamps the Marmot window (84 days, backdated an hour). MLS groups outside Marmot use other windows, and a KeyPackage's lifetime is part of the signed leaf, so it can only be chosen at creation. createKeyPackage and buildLeafNode take an optional lifetime; null keeps the Marmot window. --- .../quartz/marmot/mls/group/MlsGroup.kt | 8 ++- .../mls/group/KeyPackageLifetimeTest.kt | 58 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index adbf3a6c9b..07920051ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -509,6 +509,8 @@ class MlsGroup private constructor( leafExtensions: List = emptyList(), capabilities: Capabilities = marmotLeafCapabilities(), keyPackageExtensions: List = emptyList(), + /** The leaf's lifetime. Null uses the Marmot window (84 days, backdated an hour for clock skew). */ + lifetime: Lifetime? = null, ): KeyPackageBundle { val initKp = X25519.generateKeyPair() val encKp = X25519.generateKeyPair() @@ -523,6 +525,7 @@ class MlsGroup private constructor( signingKey = sigKp.privateKey, capabilities = capabilities, leafExtensions = leafExtensions, + lifetime = lifetime, ) val unsigned = @@ -4254,6 +4257,7 @@ class MlsGroup private constructor( parentHash: ByteArray? = null, capabilities: Capabilities = marmotLeafCapabilities(), leafExtensions: List = emptyList(), + lifetime: Lifetime? = null, ): LeafNode { val unsigned = LeafNode( @@ -4265,7 +4269,9 @@ class MlsGroup private constructor( capabilities = capabilities, leafNodeSource = source, lifetime = - if (source == LeafNodeSource.KEY_PACKAGE) { + if (source == LeafNodeSource.KEY_PACKAGE && lifetime != null) { + lifetime + } else if (source == LeafNodeSource.KEY_PACKAGE) { // A real, bounded window. `Lifetime(0, Long.MAX_VALUE)` // used to go here, which any receiver enforcing // `foundation/key-packages.md` rejects outright: the diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt new file mode 100644 index 0000000000..89472471a3 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KeyPackageLifetimeTest { + private val alice = MlsGroup.create("11".repeat(32).hexToByteArray()) + private val bob = "22".repeat(32).hexToByteArray() + + @Test + fun theDefaultIsTheBoundedMarmotWindow() { + val lifetime = + assertNotNull( + alice + .createKeyPackage(bob, ByteArray(0)) + .keyPackage.leafNode.lifetime, + ) + assertTrue(lifetime.notAfter - lifetime.notBefore <= 84L * 24 * 3600 + 3600) + } + + @Test + fun aCallerChosenLifetimeIsUsedAndTheMemberCanBeAdded() { + val now = TimeUtils.now() + val chosen = Lifetime(now - 3600, now + 365L * 24 * 3600) + + val bundle = alice.createKeyPackage(bob, ByteArray(0), lifetime = chosen) + assertEquals(chosen, bundle.keyPackage.leafNode.lifetime) + + val result = alice.addMember(bundle.keyPackage.toTlsBytes()) + val joined = MlsGroup.processWelcome(result.welcomeBytes!!, bundle) + assertEquals(alice.epoch, joined.epoch) + } +}