mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(marmot): publish current-profile KeyPackages and groups
The Quartz half of the current profile was ready and tested; nothing in the app layer called it. KeyPackage publishing now defaults to the current profile, which is the half that decides whether anyone running the adopted spec can invite us at all — a leaf without the 0x8009 account identity proof is simply not addable to a current-profile group. `createCurrentProfileGroup` builds the group through `CurrentProfileGroupFactory` and hands it to the manager via `adoptGroup`. Group creation is the one place a group cannot be built through the manager: a leaf's identity proof covers its OWN signature key, so the keypair has to be generated and authorized by the account signer before the leaf exists. Switching the KeyPackage path surfaced the mirror image of the bug this whole effort started from. A current-profile leaf advertised only the draft app_data_dictionary extension, and a legacy group REQUIRES 0xF2EE — so our new KeyPackages were un-addable to every group that already exists. Capabilities say "this client can handle it", not "this group uses it", so the leaf now advertises both. Advertising more than a group requires is always fine; advertising less is what gets a leaf rejected. The reference-shape test now states that rule rather than asserting byte-equality with MDK's leaf. The current-profile KeyPackage event also carries neither a `relays` nor an `encoding` tag, both per transports/nostr.md: a KeyPackage is fetched from the account's own inbox relay set, so repeating the relays would be a second drifting source of truth, and the binding forbids `encoding` outright because a receiver that switched decoders on one could be steered into a different parse of the same bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
+81
-7
@@ -30,6 +30,9 @@ import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender
|
||||
import com.vitorpamplona.quartz.marmot.OutboundGroupEvent
|
||||
import com.vitorpamplona.quartz.marmot.WelcomeDelivery
|
||||
import com.vitorpamplona.quartz.marmot.WelcomeResult
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager
|
||||
@@ -490,6 +493,46 @@ class MarmotManager(
|
||||
return nostrGroupId
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CURRENT-PROFILE group (`app-components/`, the `0x8009` profile).
|
||||
*
|
||||
* The difference from [createGroup] is what the group requires of its
|
||||
* members: a current-profile group's GroupContext requires the account
|
||||
* identity proof component, and every member leaf carries one. That is the
|
||||
* interop line — a peer running the current profile refuses a leaf without
|
||||
* it, and a legacy group cannot be upgraded into one by adding an
|
||||
* extension, because the existing leaves have no proofs to add.
|
||||
*
|
||||
* The group is built outside the manager because a leaf's identity proof
|
||||
* covers its OWN signature key, so the keypair must exist and be authorized
|
||||
* by the account signer before the leaf is built.
|
||||
*/
|
||||
suspend fun createCurrentProfileGroup(
|
||||
nostrGroupId: HexKey,
|
||||
relays: List<String>,
|
||||
profile: GroupProfileV1? = null,
|
||||
additionalAdmins: List<ByteArray> = emptyList(),
|
||||
retention: MessageRetentionV1? = null,
|
||||
): HexKey {
|
||||
Log.d("MarmotManager") { "createCurrentProfileGroup($nostrGroupId): by ${signer.pubKey.take(8)}…" }
|
||||
val group =
|
||||
CurrentProfileGroupFactory.createGroup(
|
||||
signer = signer,
|
||||
nostrGroupId = nostrGroupId.hexToByteArray(),
|
||||
relays = relays,
|
||||
profile = profile,
|
||||
additionalAdmins = additionalAdmins,
|
||||
retention = retention,
|
||||
)
|
||||
groupManager.adoptGroup(nostrGroupId, group)
|
||||
// Same empty-obligation exception as [createGroup]: a one-member
|
||||
// epoch-0 group has no peer that failure to publish could fork.
|
||||
publishGate.satisfyEmptyObligation(nostrGroupId)
|
||||
inboundProcessor.trackGroup(nostrGroupId)
|
||||
subscriptionManager.subscribeGroup(nostrGroupId)
|
||||
return nostrGroupId
|
||||
}
|
||||
|
||||
/** A locally prepared commit, published and resolved. */
|
||||
class CommitPublication(
|
||||
val event: OutboundGroupEvent,
|
||||
@@ -773,22 +816,53 @@ class MarmotManager(
|
||||
suspend fun generateKeyPackageEvent(
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
slotName: String = KeyPackageUtils.PRIMARY_SLOT,
|
||||
/**
|
||||
* Publish a current-profile KeyPackage, carrying the account identity
|
||||
* proof in its leaf.
|
||||
*
|
||||
* This is the decisive interop switch. A peer running the current
|
||||
* profile requires component `0x8009` and refuses a leaf without it, so
|
||||
* a legacy KeyPackage is simply not addable to a current-profile group
|
||||
* — which is what kept us uninvitable.
|
||||
*/
|
||||
currentProfile: Boolean = true,
|
||||
): KeyPackageEvent {
|
||||
val dTag = keyPackageRotationManager.getOrCreateSlotDTag(slotName)
|
||||
val identity = signer.pubKey.hexToByteArray()
|
||||
val bundle = keyPackageRotationManager.generateKeyPackage(identity, dTag)
|
||||
val bundle =
|
||||
if (currentProfile) {
|
||||
keyPackageRotationManager.generateCurrentProfileKeyPackage(signer, dTag)
|
||||
} else {
|
||||
keyPackageRotationManager.generateKeyPackage(identity, dTag)
|
||||
}
|
||||
|
||||
val keyPackageBytes = bundle.keyPackage.toTlsBytes()
|
||||
val keyPackageBase64 = Base64.encode(keyPackageBytes)
|
||||
val keyPackageRef = bundle.keyPackage.reference().toHexKey()
|
||||
|
||||
val template =
|
||||
KeyPackageEvent.build(
|
||||
keyPackageBase64 = keyPackageBase64,
|
||||
dTagSlot = dTag,
|
||||
keyPackageRef = keyPackageRef,
|
||||
relays = relays,
|
||||
)
|
||||
if (currentProfile) {
|
||||
// Deliberately no `relays` tag and no `encoding` tag.
|
||||
// `transports/nostr.md`: a KeyPackage is fetched from the
|
||||
// account's own inbox relay set, so repeating them here would
|
||||
// be a second, drifting source of truth; and the binding
|
||||
// forbids an `encoding` tag outright, because a receiver that
|
||||
// switched decoders on one could be steered into a different
|
||||
// parse of the same bytes.
|
||||
KeyPackageEvent.buildCurrentProfile(
|
||||
keyPackageBase64 = keyPackageBase64,
|
||||
dTagSlot = dTag,
|
||||
keyPackageRef = keyPackageRef,
|
||||
appComponentIds = emptyList(),
|
||||
)
|
||||
} else {
|
||||
KeyPackageEvent.build(
|
||||
keyPackageBase64 = keyPackageBase64,
|
||||
dTagSlot = dTag,
|
||||
keyPackageRef = keyPackageRef,
|
||||
relays = relays,
|
||||
)
|
||||
}
|
||||
|
||||
val signed = signer.sign<KeyPackageEvent>(template)
|
||||
// Welcome receivers identify the consumed KeyPackage by its Nostr
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Marmot: resync against the adopted spec and current MDK
|
||||
|
||||
Status: Stages 0-6 landed in Quartz, convergence is ON the inbound path, and the superseded
|
||||
`CommitOrdering` tiebreak is deleted. The app layer still creates MIP-era groups. Stage 7 open.
|
||||
Status: Stages 0-7 landed. Convergence is on the inbound path, the superseded `CommitOrdering`
|
||||
tiebreak is deleted, the app layer publishes current-profile KeyPackages and can create
|
||||
current-profile groups, and publish-before-apply is enforced. The MDK interop harness has still
|
||||
never been run against a live MDK build.
|
||||
|
||||
Sources checked on 2026-09-08:
|
||||
|
||||
@@ -524,8 +526,29 @@ A group that goes quiet mid-pass has no inbound traffic to tick it, so the app l
|
||||
`settleDueConvergence()` from a timer while `openConvergencePasses()` is non-empty; that timer
|
||||
is not wired in `commons`/`amethyst` yet.
|
||||
|
||||
**Stage 7 — durability/restart conformance, app payload kinds (1009/1210), encrypted-media
|
||||
v2, push owner proof.**
|
||||
**Stage 7 — app payloads, encrypted media v2, push owner proof. DONE.**
|
||||
|
||||
The app-payload work turned up a live conformance bug rather than a gap: we were sending inner
|
||||
events WITH a Nostr signature, and a conformant decoder rejects a payload carrying a `sig`
|
||||
member at all. Every message we sent was refusable by any spec-following peer. `MarmotAppEvent`
|
||||
is the canonical unsigned shape; the id is unchanged by the switch (NIP-01 never hashed the
|
||||
signature), so existing history still lines up, and the Android pipeline already treated inner
|
||||
events as unsigned rumors, so the empty `sig` is re-added at the inbound boundary instead of
|
||||
travelling on the wire.
|
||||
|
||||
Duplicate-key detection needed its own scanner. Every JSON library here resolves duplicates
|
||||
before the caller sees them, and "last one wins" versus "first one wins" are both defensible —
|
||||
which is the problem, because identical bytes would then yield different ids on two clients.
|
||||
|
||||
Kinds `1009` (edits) and `1210` (system rows) are implemented, the latter synthesized from
|
||||
canonical state rather than received, which is what makes it unforgeable by one member.
|
||||
Encrypted-media v2 (`0x800b`) and the kind-`451` push owner proof are implemented and verified
|
||||
against the fixtures the spec publishes — the 1210 example's event id and the push removal
|
||||
vector's `owner_sig` both reproduce exactly, which is what separates correct from
|
||||
self-consistent.
|
||||
|
||||
**Stage 7 leftover:** durability/restart conformance beyond the publish obligation (which IS
|
||||
durable) — specifically re-emission and reconstruction of application effects after restart.
|
||||
|
||||
### Settled: Quartz keeps its own MLS
|
||||
|
||||
@@ -563,19 +586,15 @@ Writing the producer side immediately found two bugs the reader-side tests could
|
||||
|
||||
## What is NOT done
|
||||
|
||||
- **The app layer still creates MIP-era groups.** `MarmotManager.createGroup` takes a
|
||||
`MarmotGroupData`; nothing in `commons`, `amethyst`, `desktopApp` or `cli` calls
|
||||
`CurrentProfileGroupFactory` yet. The Quartz half is ready and tested; the wiring is not
|
||||
written.
|
||||
- **Nothing drives a quiet group's pass to settle.** Convergence is on the inbound path and
|
||||
settles opportunistically on the next inbound event, but a group that falls silent mid-pass
|
||||
has nothing to tick it. `settleDueConvergence()` exists for the app layer's timer and no
|
||||
timer calls it yet.
|
||||
- **App-payload witnesses are never recorded.** `MarmotConvergenceEngine.recordWitness` is
|
||||
wired into scoring but has no producer, so branch comparison currently never reaches the
|
||||
witness steps. The producer is the bounded retained-candidate trial decryption still open
|
||||
from Stage 4.
|
||||
- **The lifecycle states gate nothing.** `GroupLifecycleState` is a correct model with no
|
||||
enforcement behind it.
|
||||
- **The interop harness has never been run.** Everything above is verified against the spec's
|
||||
own published fixtures and against our own MLS stack talking to itself. Neither proves we
|
||||
interoperate with a live MDK build; that needs MDK's pinned toolchain and a local relay.
|
||||
- **`MarmotManager.createGroup` (the MIP-era path) is still the one the UI calls.**
|
||||
`createCurrentProfileGroup` exists, is wired, and is tested, but the Android and desktop
|
||||
"new group" flows still call the legacy one. KeyPackage publishing HAS switched: it now
|
||||
defaults to the current profile, which is the half that decides whether anyone can invite us.
|
||||
- **Lifecycle enforcement covers the publish path, not everything.** `PendingPublish`,
|
||||
`Merging` and the outbound gates are enforced; `Unrecoverable` and `Disbanded` are still a
|
||||
correct model with nothing driving them.
|
||||
- Stage 7: durability/restart conformance, app payload kinds `1009`/`1210`, encrypted-media v2,
|
||||
the push owner proof (kind `451`).
|
||||
|
||||
+27
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.SNAPSHOT_VERSION
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
|
||||
@@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -348,6 +351,30 @@ class KeyPackageRotationManager(
|
||||
return bundle
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CURRENT-PROFILE KeyPackage and install it in [dTagSlot].
|
||||
*
|
||||
* The difference from [generateKeyPackage] is the account identity proof
|
||||
* (`0x8009`) bound into the leaf, and it is the difference that decides
|
||||
* interop: a peer running the current profile requires that component and
|
||||
* refuses a leaf without it. The account signer is needed because the proof
|
||||
* covers the leaf's OWN signature key, so the keypair has to be generated,
|
||||
* authorized, and only then built into a leaf — a proof cannot be attached
|
||||
* afterwards to a finished leaf.
|
||||
*/
|
||||
suspend fun generateCurrentProfileKeyPackage(
|
||||
signer: NostrSigner,
|
||||
dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT,
|
||||
ciphersuite: MlsCiphersuite = MlsCiphersuite.DEFAULT,
|
||||
): KeyPackageBundle {
|
||||
val bundle = CurrentProfileGroupFactory.createKeyPackage(signer, ciphersuite = ciphersuite)
|
||||
mutex.withLock {
|
||||
activeBundles[dTagSlot] = bundle
|
||||
persistUnlocked()
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active bundle for a d-tag slot.
|
||||
* Used when processing a Welcome that references one of our KeyPackages.
|
||||
|
||||
@@ -3242,10 +3242,18 @@ class MlsGroup private constructor(
|
||||
* RFC 9420 §7.2 forbids advertising DEFAULT extension types, so only
|
||||
* the draft `app_data_dictionary` extension and the `app_data_update`
|
||||
* proposal appear — `required_capabilities` support is implicit.
|
||||
*
|
||||
* The legacy `0xF2EE` group-data extension is advertised alongside
|
||||
* them, and that is not a hedge. A capability says "this client can
|
||||
* handle it", not "this group uses it", and a group that REQUIRES
|
||||
* `0xF2EE` refuses to add a leaf that does not advertise it. Without
|
||||
* this line a current-profile KeyPackage would be un-addable to every
|
||||
* legacy group that already exists — the exact mirror of the interop
|
||||
* failure the current profile was adopted to fix.
|
||||
*/
|
||||
fun currentProfileLeafCapabilities(): Capabilities =
|
||||
Capabilities(
|
||||
extensions = listOf(AppDataDictionary.EXTENSION_TYPE),
|
||||
extensions = listOf(AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT),
|
||||
proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE),
|
||||
)
|
||||
|
||||
|
||||
+19
@@ -197,6 +197,25 @@ class MlsGroupManager(
|
||||
persistGroup(nostrGroupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register and persist a group built elsewhere, e.g. by
|
||||
* `CurrentProfileGroupFactory`.
|
||||
*
|
||||
* Group creation is the one place a group cannot be built through this
|
||||
* manager: a current-profile leaf must carry an identity proof over its OWN
|
||||
* signature key, which only an account signer — possibly a remote bunker —
|
||||
* can produce, so the keypair is generated, authorized, and only then built
|
||||
* into a leaf. The manager takes ownership of the finished group here.
|
||||
*/
|
||||
suspend fun adoptGroup(
|
||||
nostrGroupId: HexKey,
|
||||
group: MlsGroup,
|
||||
) = mutex.withLock {
|
||||
require(!groups.containsKey(nostrGroupId)) { "Group $nostrGroupId already exists" }
|
||||
groups[nostrGroupId] = group
|
||||
persistGroup(nostrGroupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active Nostr group IDs.
|
||||
*/
|
||||
|
||||
+15
-2
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.marmot.appComponents
|
||||
|
||||
import com.vitorpamplona.quartz.TestResourceLoader
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary
|
||||
@@ -104,8 +105,20 @@ class CurrentProfileGroupFactoryTest {
|
||||
)
|
||||
assertContentEquals(ByteArray(0), kpDictionary[AppComponentIds.LAST_RESORT_KEY_PACKAGE])
|
||||
|
||||
// Capabilities advertise exactly the draft extension and proposals.
|
||||
assertEquals(listOf(AppDataDictionary.EXTENSION_TYPE), kp.leafNode.capabilities.extensions)
|
||||
// Capabilities advertise the draft extension the current profile
|
||||
// needs, plus the legacy 0xF2EE group-data extension.
|
||||
//
|
||||
// The extra entry is deliberate and is NOT drift from the MDK
|
||||
// reference. A capability says "this client can handle it", and a
|
||||
// group that REQUIRES 0xF2EE refuses to add a leaf that does not
|
||||
// advertise it — so without this a current-profile KeyPackage
|
||||
// would be un-addable to every legacy group that already exists.
|
||||
// Advertising more than a group requires is always acceptable;
|
||||
// advertising less is what gets a leaf rejected.
|
||||
assertEquals(
|
||||
listOf(AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT),
|
||||
kp.leafNode.capabilities.extensions,
|
||||
)
|
||||
assertTrue(
|
||||
kp.leafNode.capabilities.proposals
|
||||
.contains(0x0008),
|
||||
|
||||
Reference in New Issue
Block a user